Roadmap and testing gates¶
Six phases, each a pull request or two. P1 to P5 have shipped, in pipekit-ops 0.2.1; P6 is still deferred.
| Phase | Status | Pull requests |
|---|---|---|
| P1 | shipped | #209 |
| P2 | shipped | #210 (Map, execution.py), #211 (presets rebuilt on Map) |
| P3 | shipped | #215 |
| P4 | shipped | #216 |
| P5 | shipped | #217 |
| P6 | deferred | none yet |
Each phase below keeps its gate table as it was written, then says briefly what was measured when it landed. The full numbers are in each pull request's description.
A phase is done when every gate in its table passes in CI, together with the four checks of the repository's pre-commit checklist (tests, ruff check ., ruff format --check ., ty check).
Gates that apply to every phase:
| ID | Gate | Pass condition |
|---|---|---|
| G0-1 | Round trip | every public operator added in the phase passes pipekit_ops.testing.assert_round_trip(op, x) on a fixed input: loads(dumps(op)) has the same pipeline_hash and returns an equal output |
| G0-2 | Sweep | public_operators(pipekit_ops) lists no operator without a round-trip test case |
| G0-3 | No dependencies | packages/pipekit-ops/pyproject.toml still has dependencies = [], and importing pipekit_ops imports no module outside the stdlib (checked from sys.modules in a fresh interpreter) |
| G0-4 | No flags | no class added by this design sets forbid_in_yaml = True |
P1: dict plumbing and iterables¶
Adds Get, GetIn, Pick, Dissoc, Rename, Merge, Assoc, UpdateIn, ValMap, Not, Filter, Remove, Reduce, PartitionAll, Concat, Zip, GroupBy, CountBy, Count, Unique, Take, SortBy.
| ID | Gate | Pass condition |
|---|---|---|
| G1-1 | toolz agreement | for 200 random inputs per operator (Hypothesis, fixed seed), each operator's output equals its toolz counterpart's, converted to a list where toolz is lazy: Get/get, GetIn/get_in, Dissoc/dissoc, Merge/merge, ValMap/valmap, UpdateIn/update_in, Filter/filter, Remove/remove, PartitionAll/partition_all, Concat/concat, GroupBy/groupby, CountBy/countby, Count/count, Unique/unique, Take/take; toolz is a test-only dependency |
| G1-2 | No mutation | for every dict operator, the input's stable_json before and after the call are equal |
| G1-3 | Strict zip | Zip()([1, 2], [1]) raises ValueError; Zip(strict=False) returns [(1, 1)] |
| G1-4 | Callables refused | passing a bare lambda as a predicate or key to any P1 operator raises TypeError whose message contains Lambda |
| G1-5 | Running example | the example's search-and-screen Graph over a fixture of 300 STAC item dicts returns n_found == 300 and the n_kept and per_tile values computed by a plain Python loop |
Risk: the new names crowd the top-level namespace (about 25 names).
Fallback: export them from pipekit_ops.dicts and pipekit_ops.seq only, and add top-level aliases later if users ask.
Shipped in #209, with all 22 operators also exported at top level (the fallback was not needed). Measured:
- G1-1: Hypothesis with
max_examples=200and a fixed seed; the 15 operators with a toolz counterpart agree with it, and the other 7 are checked against plain Python. - G1-3:
Zip(strict=False)([1, 2], [1]) == [(1, 1)]. - G1-4: a bare
lambdais refused by all 12 operators that take a predicate, key or function. - G1-5: over 300 STAC-like item dicts the graph gives
n_found == 300, withn_keptandper_tileequal to a plain loop, and the graph round-trips. - G0-1 to G0-4: all 22 operators are in the round-trip sweep; a fresh interpreter that imports
pipekit_opsloads only stdlib modules (tests/test_no_dependencies.py).
P2: Map, executors, presets, Prefetch, Fold¶
Adds Map, executor_scope, Prefetch, Fold, MapError, rebuilds the four presets on Map, and gives Cache a __getstate__ (decision O10) so it can go to a process pool.
| ID | Gate | Pass condition |
|---|---|---|
| G2-1 | Same outputs | for 1,000 integer items and an operator that sleeps a random 0 to 2 ms, the serial, thread, process and async executors and a borrowed ThreadPoolExecutor(4) return identical lists, in input order |
| G2-2 | Lazy input | Map(op, executor="thread", n_workers=4, max_in_flight=8) over a generator of 10,000 items never has more than 8 items pulled and not yet collected (a counting generator records the maximum) |
| G2-3 | Fail fast | with 2 workers, 100 items of 0.2 s and item 0 raising, on_error="raise" raises within 0.5 s on threads and within 1.5 s on processes (spawn start-up included); today ProcessMap takes about 10 s for the same input |
| G2-4 | Collect | with items 3 and 7 of 10 raising, on_error="collect" raises a MapError with exactly 2 sub-exceptions, notes naming items 3 and 7, and results holding the 8 other outputs in their slots |
| G2-5 | Log and continue | with item 3 of 10 raising, on_error="log_and_continue" returns 10 results with None at index 3 and emits exactly 1 warning on pipekit_ops.parallel |
| G2-6 | Hashes kept | pipeline_hash of ThreadMap(Identity(), n_workers=4), ProcessMap(Identity()), AsyncMap(Identity()), BatchedMap(Identity()) equals, respectively, be9cbf3c…20bd, df4f9dd1…765b, 2132e0bf…b081, 70dec7fb…bf90 (full digests recorded at 903b0c3 in the test fixture) |
| G2-7 | Pickling caught early | Map(Lambda(abs), executor="process")([1]) raises TypeError naming Lambda before any worker starts; Map(Cache(Identity()), executor="process")([1, 2]) returns [1, 2] |
| G2-8 | Tracers | under jax.jit, Map(op, executor="thread") raises TypeError, and Map(op, executor="serial") returns the same values as the eager call (skipped when JAX is not installed) |
| G2-9 | Running loop | inside a running event loop, Map(op, executor="async")(items) raises RuntimeError whose message contains run, and await Map(op, executor="async").run(items) returns the eager result |
| G2-10 | Borrowed executor stays open | after Map(..., executor="scope") runs under executor_scope(pool), pool.submit(abs, -1).result() == 1 |
| G2-11 | Prefetch depth | Prefetch(op, depth=4) over 100 items never has more than 4 items pulled and not yet consumed; with item 0 blocked on an event, at most 3 later results finish and at most 4 items are pulled before the event is set |
| G2-12 | Fold equals reduce | for 10,000 random integers, Fold(AddPair(), 0, chunksize=128, executor="thread") equals sum(items), where AddPair is a test operator returning acc + item |
| G2-13 | Nested maps | a Map(executor="scope") inside a Map task under a 2-worker pool completes 10 outer × 10 inner items within 5 s (no deadlock) |
| G2-14 | Ordered iterator window | Map(op, executor="thread", n_workers=4, max_in_flight=4).iter(items, ordered=True) over 100 items, with item 0 blocked on an event for 1 s, holds at most 4 items (running, queued or buffered) at every point, and yields op(0) … op(99) in order once the event is set |
Risk: spawn changes ProcessMap for Linux users whose scripts lack a __main__ guard.
Fallback: keep start_method=None (platform default) on the ProcessMap preset for one minor release, with a FutureWarning when the default would differ, and use spawn only on Map(executor="process").
Risk: the backpressure loop is the most intricate code in core.
Fallback: ship Map with the presets unchanged first, and move the presets onto Map in a second pull request once G2-1 to G2-5 hold for Map.
Shipped in two pull requests, as the second fallback suggests: #210 adds Map, executor_scope, MapError, Failed, Prefetch and Fold, and #211 rebuilds the four presets on Map.
The first fallback was taken too: ProcessMap keeps the platform's start method and raises a FutureWarning when that is not spawn, while Map(executor="process") uses spawn.
Measured:
- G2-2: at most 7 items were pulled and not yet collected, with a window of 8.
- G2-3: threads raised in 0.40 s (bound 0.5 s);
Mapon processes under spawn in 0.77 to 0.87 s, andProcessMapin 0.429 s (bound 1.5 s). The wall-clock tests are markedslow, so CI skips them and runs timing-free versions instead (forProcessMap: with 2 workers over 100 items and item 0 failing, at most 10 items start). - G2-6: the four preset hashes are pinned in a test and unchanged.
- G2-8: run with JAX 0.10.1.
- G2-11: with the head item blocked, at most 4 items were held over the whole run.
- G2-13: 10 × 10 nested items finish in about 0.002 s.
- G2-14: at most 4 items held; the run took 1.005 to 1.009 s against the 1 s block, with results in order.
- G2-1, G2-4, G2-5, G2-7, G2-9, G2-10, G2-12: pass.
P3: Timeout, RateLimit, Until¶
| ID | Gate | Pass condition |
|---|---|---|
| G3-1 | Timeout fires | Timeout(op, seconds=0.1) around an operator that sleeps 1 s raises TimeoutError within 0.15 s |
| G3-2 | Timeout passes | the same wrapper around an operator that sleeps 0.01 s returns its output |
| G3-3 | Retry on timeout | Retry(Timeout(op, 0.1), attempts=3, base_delay=0, on=(TimeoutError,)) around an operator that hangs twice then returns, returns within 0.35 s |
| G3-4 | Rate | RateLimit(op, calls=10, period=1.0) called 50 times from 8 threads takes between 4.0 s and 4.5 s, and no window of 1 s contains more than 10 recorded start times |
| G3-5 | Until bound | Until(op, pred, max_iter=5) with a predicate that is never true raises RuntimeError after exactly 5 calls of op; with on_max="return" it returns \(x_5\) |
| G3-6 | Until early | with a predicate already true on the input, op is called 0 times |
| G3-7 | Async children refused | Retry, Timeout and RateLimit wrapping an operator with async def _apply raise TypeError at construction |
Risk: abandoned threads from Timeout pile up when a service hangs for good.
Fallback: the max_abandoned warning, and Timeout(…, isolate="process") in a later phase.
Shipped in #215, in the module pipekit_ops.flow.
The max_abandoned warning shipped; isolate="process" has not.
Measured:
- G3-1:
Timeoutfired in 0.1006 s (limit 0.15 s). - G3-2: a fast
opreturned in 0.0106 s. - G3-3: 3 calls returned in 0.2029 s (limit 0.35 s).
- G3-4: 50 calls from 8 threads took 4.0021 s, with at most 10 starts in any 1 s window.
- G3-5 and G3-6:
Untilraises after exactly 5 calls (withon_max="return"it returns \(x_5\)), and callsop0 times whenpredalready holds. - G3-7:
Untilrefuses async children too, for bothopandpred, as decision O15 asks, so four operators pass the gate rather than three.
The wall-clock tests for G3-1, G3-3 and G3-4 are marked slow; each has a deterministic twin, on events or a fake clock, that runs in CI.
P4: Store and the disk cache¶
| ID | Gate | Pass condition |
|---|---|---|
| G4-1 | Default unchanged | Cache(Identity()) has pipeline_hash 24b9f307…0149, as at 903b0c3, and the same hits / misses sequence as today on the core-tour example ((1, 2)) |
| G4-2 | Survives a restart | a Cache(op, store="s") over a DirectoryStore computes 100 items once in one process; a second process with a fresh Cache returns the same 100 outputs with misses == 0 |
| G4-3 | Atomic writes | 8 processes writing the same 100 keys concurrently leave 100 files, each loadable and equal to the computed value |
| G4-4 | Pickles | pickle.loads(pickle.dumps(Cache(op))) works, and the copy has no entries |
| G4-5 | Key by field | Cache(op, key=Get("id")) called on two dicts with the same id and different other fields runs op once |
| G4-6 | Unbound name | calling Cache(op, store="missing") outside any store_scope raises LookupError naming missing |
| G4-7 | Class in key | Cache(A()) and Cache(B()), where A and B are different classes with equal configs, never share an entry in one store |
Risk: moving the config part of the key to pipeline_hash(inner) invalidates nothing on disk today (there is no disk backend), but a later change of pipeline_hash framing would.
Fallback: a DirectoryStore writes a pipekit-store.json with a format version, and refuses a directory with another version.
Shipped in #216, with the fallback built in from the start: pipekit-store.json records version 1.
Measured:
- G4-1:
pipeline_hash(Cache(Identity()))is still24b9f307…0149(pinned), the config is still{"inner": …}, and the core-tour example still gives(1, 2). - G4-2: a first spawned process gives hits and misses
(0, 100); a second one, with a freshCache, gives(100, 0)and identical outputs. - G4-3: 8 spawned writers saving the same 100 keys in shuffled order (20 KB values) leave exactly 100 files and no temporary files, all loadable and correct.
- G4-6: the
LookupErrornames'missing'both outside any scope and inside another scope. - G4-7:
A()andB()with equal configs give 2 entries in one shared store. - G4-4 and G4-5: pass.
P5: Log, progress, AssertThat, AssertSignature¶
| ID | Gate | Pass condition |
|---|---|---|
| G5-1 | Log pass-through | Log() returns its input object unchanged (is) and emits exactly one record per call |
| G5-2 | Progress | Map(op, progress=10) over 100 items emits exactly 10 progress records, the last with done == 100 |
| G5-3 | AssertThat | AssertThat(Const(False))(x) raises QCError; AssertThat(Const(True))(x) is x |
| G5-4 | AssertSignature | with a registered adapter, a carrier with dims band y x passes "band y x" and "... y x", and fails "time band y x" with QCError; an unregistered carrier raises with missing="raise" and passes with missing="skip" |
| G5-5 | Build-time check | Sequential.summary() of a pipeline whose upstream signature is time y x followed by AssertSignature("band y x") reports the mismatch without running data |
Shipped in #217.
G5-1 to G5-5 pass (tests/test_observe_qc_p5.py, 32 tests).
progress is left out of Map's config when it is None, and a test pins pipeline_hash(Map(Identity())) at b5098ac5…, its value before this phase, so every existing Map and preset keeps its hash.
P6: async-aware wrappers (deferred)¶
Retry, Timeout and RateLimit gain an _apply_async used by Map(executor="async"), so an async read can be retried and rate-limited on one event loop.
Its gates are written with its design; the P3 refusal (G3-7) is lifted operator by operator.
Nothing of P6 has been built yet.
Order and dependencies¶
P1 dict plumbing, iterables
│
▼
P2 Map, executors, presets ──► P3 Timeout, RateLimit, Until
│ │
▼ ▼
P4 Store, disk cache ────────► P5 Log, progress, asserts
│
▼
P6 async-aware wrappers
P1 has no dependencies and unblocks Fold and BatchedMap's rebuild.
P3 needs P2 only for Timeout's shared pool and the async refusal; it can start in parallel once Map's worker helpers exist.
As built, Timeout starts a daemon thread per call instead of using a shared pool (see decisions.md), and the five phases landed as a stack of pull requests in the order P1, P2, P3, P4, P5.
P4 needs Get from P1 for key= and the pickling rules from P2.
P5 needs Map for progress.