Limitations
What Lucen does not do, where it leaves performance on the table, and the boundary of its correctness guarantee. Nothing here is a case where Lucen silently returns a wrong answer inside its own contract. If you hit something that behaves worse than this document promises, that is a bug.
Planned work against these items is in ROADMAP.md, the measured numbers are in BENCHMARK.md, and the full semantics are in the technical specification.
1. The correctness boundary
Lucen guarantees a marked file behaves identically to the same file with the pragmas treated as comments, provided your objects and helpers tell the truth about themselves. Two adversarial red-team campaigns, over 130 scenarios across a black-box third-party posture and a competitor posture, found that every silent divergence reduces to one of the three items below, each of them code the analyzer cannot see inside. This is the trust contract: enforced where enforcement is possible, documented where it is not.
Everything outside it is either executed bit-identically or refused loudly, including aliasing, cross-iteration dependencies, ordering-sensitive reductions, exception types and prefixes, hostile containers, and malformed pragmas.
1.1 Helper purity beyond readable source
A helper that carries hidden mutable state (mutates a module global, advances a
closure cell, consumes random state, performs I/O) is correct sequentially but
can diverge per worker on the process backend, where each worker holds its own
copy of that state.
The purity proof (spec 5.4) statically reads the helper's source where it can, and a helper it proves stateful makes its block run sequentially with a report naming it. That closes the case for any helper whose source is importable and analyzable. What remains is helpers the analyzer cannot read: C extension functions, and callables reached through dynamic dispatch it cannot resolve to a definition. Those keep the documented args-as-reads trust, and a stateful one hiding behind that boundary diverges silently.
Keep helpers called from a marked block pure with respect to hidden state. If
one is safe but unreadable (a C-level function you know is pure), assert it with
# LUCEN TRUST on its def, trust=callables on the block, or a
[trust] callables entry in lucen.toml.
Severity: medium, the highest here. Bounded to unreadable stateful helpers; the readable case is proven and downgraded automatically.
1.2 Faithful serialization
The process backend ships argument bundles by pickle, so an object whose serialization does not preserve value would arrive at the worker changed. The preflight gate (spec 5.13) verifies that the first chunk's bundle reaches a byte fixed point after one round trip; an accumulating serializer never converges and the block falls back sequentially and loudly. This is what catches the demonstrated silent-corruption exploit, an object that adds a constant on every round trip.
A serializer that oscillates with a period greater than one can pass the
convergence check while still not being value-preserving in transit. That is a
pathological serializer, and the check catches the common accumulating form
rather than every conceivable period. Objects passed into a marked block must
pickle faithfully, which multiprocessing already expects; trust=pickle
waives the check when you have verified the object yourself.
Severity: low. Requires a deliberately or unusually broken serializer.
1.3 Two explicit assertions
depend=none asserts your indexed writes are disjoint. skip_runtime_check=true
additionally disables the runtime write-set audit that would catch a false
depend=none. With both asserted on a block whose writes actually overlap,
Lucen produces a wrong result, exactly as the clauses say they will.
This is the documented escape hatch for an expert who has proven disjointness
the analyzer cannot, and red-team testing confirmed the property that matters:
one assertion is never enough. depend=none alone on a real dependency is still
caught by the tier-C audit and re-runs sequentially. Do not add
skip_runtime_check=true unless you have independently proven the writes are
disjoint; if you are unsure, drop it and let the audit protect you at a small
runtime cost.
Severity: low by design. Reachable only by two deliberate expert waivers.
2. Inherent parallel semantics
These behaviors differ from sequential execution. The committed result is still bit-identical, but they are observably different if your code looks at them, and they cannot be fixed without giving up parallelism.
2.1 Side-effect order is not sequential
Inside a block that parallelizes while still performing side effects, the
count of every side effect is exact but the order is not. Two hundred
print statements from a parallel body all appear exactly once each,
interleaved by chunk rather than in loop order.
Where a side effect is statically detectable the purity proof routes the block sequentially precisely to preserve order, so this surfaces mainly under an explicit trust assertion that overrides that proof. If a block's side effects must be ordered, do not force it parallel; consumers of unordered side effects, such as counters and independent writes, are unaffected.
Severity: low. Count fidelity is exact; order fidelity is not promised for side-effecting parallel blocks.
2.2 Executor-observing code sees workers
Code that observes its execution environment (os.getpid(), thread identity,
thread-local state) sees the worker that ran the iteration rather than the
single process or thread it would see sequentially. This is intrinsic to running
on more than one worker: if a body's result depends on where it runs, it is not
a parallelizable body.
Severity: low. Observing the executor is outside the value contract; the computed result is unaffected.
2.3 Spawn platforms need the __main__ guard
On spawn platforms (Windows, and macOS by default) the process backend
re-imports the entry module in every worker, so an entry script that does work
at import time would re-run that work in each worker. The spawn-safety scan
(spec 5.10) detects an unguarded entry script in the parent, before any worker
spawns, and runs the block sequentially with an actionable message instead of
the child-side error flood multiprocessing would produce. The result stays
correct; the cost is that the block does not parallelize. Put top-level work
behind if __name__ == "__main__":, as multiprocessing already requires.
Severity: low. Detected and handled; the fix is a one-line guard.
2.4 Destructors run in the worker that owns the object
An object created and released inside the loop body has its __del__ run on the
worker. Under the process backend the object never exists in the parent at all,
so a destructor side effect (appending to a module list, flushing a handle) is
performed in the child and never observed by the parent.
The purity proof does not catch this: a destructor is not called anywhere in the
source it reads, it is attached to a type and fires when the interpreter
releases the object, so a body that allocates such an object reads as pure. Do
not rely on __del__ for program-visible effects in a marked loop; release
timing is an implementation detail in plain Python too. Use an explicit
close(), a with block, or return the value and act on it after the loop.
Severity: moderate, and narrow. What differs is the context a destructor runs in and its parent-visible effect, which the value contract does not cover.
2.5 Exception type can degrade across the process boundary
An exception raised on the process backend crosses a pickle boundary before it
reaches you. The traceback never survives that crossing, because pickle does not
carry __traceback__, and in one narrow case the type does not either.
Each worker-side exception is round-trip tested. One that pickles is returned as
itself, with type, message and arguments intact. One that does not is replaced
by a proxy carrying its module, qualname and message, which the parent
re-imports and rebuilds by calling the type with the message as its single
argument. An exception whose constructor needs more than that cannot be
reconstructed, and the parent raises RuntimeError carrying the original
qualname and message in its text. Such a block raises RuntimeError under the
process backend and its own type under sequential or backend=thread;
on_error=collect degrades the same way, through the same path.
Keep exceptions that can escape a marked block picklable. Where the type matters
and cannot be made picklable, pin the block with backend=thread, which raises
the original exception object with its traceback intact.
Severity: low, and confined to the error path. The message survives in every case, the committed values are untouched, and the degradation is loud.
2.6 Process workers multiply the memory footprint
A process worker is a separate interpreter holding its own copy of whatever the loop body reaches. On a spawn platform each worker re-imports the entry module and the module graph behind the body, so peak memory is roughly the parent's footprint plus one import graph per worker; on a fork platform the children start as copy-on-write images, which is far cheaper. Either way the growth arrives at the first process dispatch rather than accumulating, and the multiplier is the size of your imports, not the size of your data.
The pool is created once per process and reused across every block, so a program
with twenty marked blocks pays for one set of workers, and a container read only
as P[i] ships as a slice rather than whole. Size the pool for your import
graph rather than your core count: backend=process(pool_size=N) on the block,
[defaults] pool_size for the project, or [limits] max_processes_per_block as
a ceiling. A body that is not helped by separate address spaces can take
backend=thread instead, as can any block on a free-threaded build.
Severity: low to moderate, in proportion to how heavy your imports are. No effect on results; when it bites, it is memory pressure at the first dispatch on a many-core machine.
3. Performance gaps
Here Lucen is correct but slower than it could be, because the gate or the routing is conservative or a faster path is not yet the default. None of these affect output.
3.1 typed_buffers is not in the cost model
The experimental typed_buffers flag ships typed result slabs (array or
bytearray) back from process workers instead of a list of the same floats,
which makes the forced-process buffer map 2.5x to 3.2x faster and brings it
from 2.7x to 4.4x behind hand-written code to parity with it
(BENCHMARK.md). The profitability gate does not yet account for
that cheaper transfer, so it routes array-output maps to sequential even where
the typed process path would win.
For a dense array-output map, enable the flag and force the backend:
activate(experimental=["typed_buffers"]) plus # LUCEN START backend=process.
Teaching the cost model the typed transfer cost is ROADMAP N1.
3.2 Light reductions carry a small probe overhead
A trivial reduction over a large input carries a 4 to 14 percent probe overhead
relative to sequential, measured across the seven benchmark interpreters
(BENCHMARK.md), because reductions cannot yet use the
twin-probe fast path that pure maps use: a reduction's sequential twin is
functional rather than in-place, so probing it still needs a chunk-function
slab. Nothing is required of you; if a specific light reduction is hot,
calibrate=false forces the parallel path. A reduction twin-probe is
ROADMAP N2.
3.3 The recognized-DAG wavefront runs sequentially by default
A recognized-DAG block (results[i] = combine(results[i // 2], ...)) has a
level-synchronous parallel form (spec 5.8), but it runs sequentially by default:
on a GIL build one pickled dispatch per level loses badly. On a free-threaded
build, add backend=thread to a recognized-DAG block heavy enough to benefit.
Making the gate reach for it there is ROADMAP M1.
3.4 No native core on free-threaded builds
The native accelerator ships as one abi3 wheel that loads on GIL builds 3.9
through 3.14. The stable ABI and the free-threaded ABI are mutually exclusive,
so on a free-threaded interpreter pip install lucen selects the published
py3-none-any wheel and Lucen runs its pure-Python fallback. Only the
acceleration is absent, and the impact is small: the native core accelerates two
orchestration primitives, not the loop body or the dispatch, and the
free-threaded build posts competitive benchmark numbers without it. A separate
non-abi3 build is ROADMAP M2.
3.5 The loop body itself is never compiled
Lucen parallelizes the loop; it does not compile the loop body. Every iteration runs the same interpreted bytecode it would run sequentially, on whichever worker executes it, so for a body that is interpreter work over Python objects the speedup is bounded by the core count and the interpreter rather than by native code generation. Native compilation of a provably-typed numeric subset is ROADMAP L1, the largest item there and compiler-scale work.
4. Scope limits
What Lucen does not attempt. These are boundaries of the design, not defects.
4.1 One block per pragma pair, one construct per block
A # LUCEN START / # LUCEN END pair marks exactly one for loop, or one
assignment of a list, dict, or set comprehension. The body may contain
arbitrarily nested control flow, but the marked construct itself is a single
loop or comprehension over a sized iterable. A marked while has no iteration
space to chunk and runs as unmodified Python, as do async bodies and async
comprehensions.
A bare generator expression is never parallelized. It is lazy: nothing is computed where it is written, so producing its elements at the marked line would change when the work happens, force the whole result into memory, and diverge outright if the consumer takes only a prefix or mutates the source first.
total = sum(elt for t in it) is the exception, because sum provably drains
the generator at that line and keeps none of it. Only the elements are
parallelized; they land in one positional list and sum itself then runs over
that list, sequentially, after the join. Reusing the builtin is what keeps the
result exact, since sum carries a compensation term for floats on CPython 3.12
and newer and a + fold over the same elements would return different bits.
Further for clauses are refused: only the outermost iterable is chunked, so
each slot would hold a whole row rather than an element.
4.2 Sized iterables only
Chunked dispatch requires a known length. A marked loop over an unsized iterable
takes the UnsupportedIterableError fallback and runs sequentially. Materialize
it to a list first if you want it parallelized.
4.3 break and return are sequential outside an experimental flag
A block containing break or return runs sequentially unless the experimental
early-exit scheduler is enabled (activate(experimental=["early_exit"])), which
reproduces sequential first-break semantics speculatively.
4.4 Not a substitute for vectorized kernels
Lucen accelerates Python-level loop bodies of meaningful size. A loop that should be a single NumPy expression will be faster as that expression than as a parallelized Python loop, and Lucen does not claim otherwise.
4.5 No guaranteed speedup
The profitability gate is a bounded-cost estimator, not a promise. It can
mispredict; when it does, the cost is bounded at roughly one chunk of
suboptimal scheduling per calibration cycle, it is visible in lucen profile,
and it never changes output.