Skip to content

feat(core): PR 1 of 8 — algebra capability tags on Op<'T> base class#4558

Merged
AceHack merged 1 commit into
mainfrom
feat/op-capability-tags-2026-05-21
May 21, 2026
Merged

feat(core): PR 1 of 8 — algebra capability tags on Op<'T> base class#4558
AceHack merged 1 commit into
mainfrom
feat/op-capability-tags-2026-05-21

Conversation

@AceHack
Copy link
Copy Markdown
Member

@AceHack AceHack commented May 21, 2026

Summary

Promotes the algebra capability tags (ILinearOperator, IBilinearOperator, ISinkOperator, IStatefulStrictOperator) from plugin-only marker interfaces in PluginApi.fs to first-class properties on the Op<'T> base class in Circuit.fs.

This is PR 1 of an 8-PR campaign to wire the algebra-capability system from declarative-but-unenforced markers into a load-bearing property surface that scheduler, fusion engine, and incremental-rewriter dispatcher all consume uniformly.

Why

Before this change, the algebra tags lived only as plugin marker interfaces and PluginOperatorAdapter discarded the declarations — its capability-detection cache included IStrictOperator/IAsyncOperator/INestedFixpointParticipant but not the algebra markers. That meant:

  1. Internal operators (MapZSetOp, JoinZSetOp, etc.) had no capability surface — algebra was implicit-by-code-shape.
  2. Plugin operators declared ILinearOperator<_,_> and similar but the declaration was invisible to the scheduler.
  3. The docstring on ISinkOperator promising "the scheduler enforces terminal placement" was vapor — no enforcement existed.
  4. Consumers like Incremental.IncrementalJoin trusted the bilinear assumption by type-signature shape rather than by capability tag.

How

Non-generic marker pattern (BCL IEnumerable / IEnumerable<T>-style)

F# generic-interface runtime tests require exact type-parameter match — (box plugin) :? IBilinearOperator<obj, obj, 'TOut> against a concrete IBilinearOperator<int, string, decimal> returns false. Fix:

  • New non-generic markers: ILinearMarker, IBilinearMarker, ISinkMarker, IStatefulStrictMarker (all interface end).
  • Typed interfaces inherit the marker (e.g. type ILinearOperator<'TIn, 'TOut> = inherit IOperator<'TOut> inherit ILinearMarker).
  • Plugin authors continue implementing the typed interface; the marker is satisfied automatically via interface inheritance.

Op<'T> base class adds four properties

abstract IsLinear: bool         ; default _.IsLinear = false
abstract IsBilinear: bool       ; default _.IsBilinear = false
abstract IsSink: bool           ; default _.IsSink = false
abstract IsStatefulStrict: bool ; default _.IsStatefulStrict = false

Concrete operators override only the capabilities they actually have.

Adapter detection

PluginOperatorAdapter caches one :? check per marker at construction (zero per-tick cost) and surfaces results via the new Op overrides:

let isLinearCap         = (box plugin) :? ILinearMarker
let isBilinearCap       = (box plugin) :? IBilinearMarker
let isSinkCap           = (box plugin) :? ISinkMarker
let isStatefulStrictCap = (box plugin) :? IStatefulStrictMarker
override _.IsLinear         = isLinearCap
override _.IsBilinear       = isBilinearCap
override _.IsSink           = isSinkCap
override _.IsStatefulStrict = isStatefulStrictCap

Internal-operator overrides

Operator Capability Notes
Map, Filter, FlatMap, Neg, IndexWith IsLinear=true Distributes over Z-set addition
Delay, Integrate, Differentiate IsLinear=true Commute with the group operation
FilterMap, FilterMapOptional IsLinear=true Composition of linear ops
Join, Cartesian, IndexedJoin IsBilinear=true Per-arg linear; weight multiplication
Plus, Minus (default false) Additive but not unary-linear: Plus(0,b)=b≠0
Distinct, DistinctIncremental, GroupBySum, Constant (default false) Non-linear by construction

Tests

21 new tests in tests/Tests.FSharp/Plugin/Capabilities.Tests.fs:

  • 15 internal-operator capability assertions (one per named op)
  • 5 plugin-marker-detection tests via PluginOperatorAdapter
  • 1 negative test: plain IOperator plugin reports all caps false

Test results:

  • 21 / 21 new capability tests pass
  • 31 / 31 plugin tests pass (10 pre-existing + 21 new — no regressions)
  • 480 / 481 broader operator/algebra/circuit tests pass (1 pre-existing SKIP)
  • 0 warnings, 0 errors on full-solution Release build

Foundation for PRs 2-8

This is the load-bearing dependency for:

  • PR 2: Circuit.Build() consults IsSink for terminal-placement enforcement (the docstring promise that's currently vapor).
  • PR 4: IncrementalAuto dispatcher reads IsLinear/IsBilinear to pick Q^Δ=Q vs three-term-bilinear vs D∘Q∘I fallback.
  • PR 5: FusionEngine composes capability tags through DAG rewrite.
  • PRs 6-8: push/morsel/codegen architectures all need uniform capability surfacing to dispatch correctly.

No public-API breakage: marker interfaces work identically for plugin authors; new Op-base-class properties are purely additive.

Test plan

  • dotnet build Zeta.sln -c Release — 0 warnings, 0 errors
  • dotnet test --filter "FullyQualifiedName~CapabilitiesTests" — 21/21 pass
  • dotnet test --filter "FullyQualifiedName~Zeta.Tests.Plugin" — 31/31 pass
  • dotnet test --filter "FullyQualifiedName~Operators|Algebra|Circuit" — 480/481 pass (1 pre-existing SKIP)
  • CI green on PR

…aces onto Op<'T> base class

PR 1 of an 8-PR campaign that wires the algebra-capability system from
declarative-but-unenforced markers into a load-bearing, uniformly-detected
property surface on every operator (internal + plugin).

## What changes

`Op` base class (Circuit.fs) gains four abstract properties — `IsLinear`,
`IsBilinear`, `IsSink`, `IsStatefulStrict` — each defaulting to `false`.
Concrete operators override only the capabilities they actually have.
Until this change, the algebra tags lived ONLY as plugin marker
interfaces in PluginApi.fs and were ignored by `PluginOperatorAdapter`
(which detected `IStrictOperator`/`IAsyncOperator`/`INestedFixpointParticipant`
but not the algebra markers). That asymmetry meant:

  - Internal operators (MapZSetOp, JoinZSetOp, etc.) had no capability
    surface at all — algebra was implicit-by-code-shape.
  - Plugin operators declared capabilities via marker interfaces but
    `PluginOperatorAdapter` discarded the declarations.
  - Consumers (Incremental.IncrementalJoin, future Fusion/IncrementalAuto)
    had no uniform way to ask "is this operator linear?" without
    custom type tests per call site.

## Non-generic marker pattern

F# generic-interface tests require exact type-parameter match —
`(box plugin) :? IBilinearOperator<obj, obj, 'TOut>` against a concrete
`IBilinearOperator<int, string, decimal>` returns false. The fix is the
BCL `IEnumerable` / `IEnumerable<T>` pattern: a non-generic marker
interface (`ILinearMarker`, `IBilinearMarker`, `ISinkMarker`,
`IStatefulStrictMarker`) for runtime `:?` tests, and the typed interface
inheriting the marker. Plugin authors continue implementing the typed
interface; the marker is satisfied automatically via interface
inheritance.

`PluginOperatorAdapter` now caches one `:?` check per marker at
construction (zero per-tick cost) and surfaces the results through
the new `Op` overrides.

## Internal-operator overrides

| Operator | Capability | Reasoning |
|---|---|---|
| MapZSetOp, FilterZSetOp, FlatMapZSetOp, NegZSetOp | IsLinear=true | Z-set algebra: distributes over addition, op(0)=0 |
| IndexWithOp | IsLinear=true | Indexing distributes over per-key value-group sum |
| JoinZSetOp, CartesianZSetOp, IndexedJoinOp | IsBilinear=true | Weights multiply; per-arg linear; op(0,b)=op(a,0)=0 |
| DelayOp, IntegrateOp, DifferentiateOp | IsLinear=true | Time-shift / running-sum / difference commute with group |
| FilterMapOp, FilterMapOptionalOp | IsLinear=true | Composition of linear ops |
| PlusZSetOp, MinusZSetOp | (default false) | Additive but NOT unary-linear: Plus(0,b)=b≠0 |
| DistinctZSetOp, DistinctIncrementalOp | (default false) | Clamps weights — breaks linearity |
| GroupBySumOp | (default false) | Output keys depend on summed weights, breaks linearity |
| ConstantOp | (default false) | Affine; const_c(0)=c≠0 unless c=0 |

## Tests

21 new tests in `tests/Tests.FSharp/Plugin/Capabilities.Tests.fs`:

  - 15 internal-operator capability tests (one per named op)
  - 5 plugin-marker-detection tests via PluginOperatorAdapter
  - 1 negative test: plain IOperator plugin reports all caps false

All 31 plugin tests pass (10 pre-existing + 21 new); 480 / 481 broader
operator/algebra/circuit tests pass (1 SKIP is pre-existing). Build
clean: 0 warnings, 0 errors on full solution Release build.

## Foundation for PRs 2-8

This is the load-bearing dependency for:

  - PR 2: Circuit.Build() consults IsSink for terminal-placement
    enforcement (the docstring promise that's currently vapor).
  - PR 4: IncrementalAuto dispatcher reads IsLinear/IsBilinear to
    pick Q^Δ=Q vs three-term-bilinear vs D∘Q∘I fallback.
  - PR 5: FusionEngine composes capability tags through DAG rewrite.
  - PRs 6-8: push/morsel/codegen architectures all need uniform
    capability surfacing to dispatch correctly.

No public-API breakage: the marker interfaces still work the same
way for plugin authors; the new Op-base-class properties are
purely additive.
Copilot AI review requested due to automatic review settings May 21, 2026 17:21
@AceHack AceHack enabled auto-merge (squash) May 21, 2026 17:21
@AceHack AceHack merged commit 792948a into main May 21, 2026
32 of 33 checks passed
@AceHack AceHack deleted the feat/op-capability-tags-2026-05-21 branch May 21, 2026 17:25
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 274455aa2d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Core/Primitive.fs
/// trivially when `initial = 0` for the group. Callers passing a
/// non-zero initial are responsible for the resulting affine
/// offset — DBSP usage always passes the group zero.
override _.IsLinear = true
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Compute IsLinear from delay initial value

DelayOp is marked linear unconditionally, but the public Circuit.Delay(stream, initial) overload allows non-zero initial values (for example Fusion.Tests uses ZSet.singleton 99 1L). In that case z^-1(0) returns initial, so op(0) != 0 and the operator is affine, not linear. Reporting IsLinear = true here will let capability-driven rewrites (the planned Q^Δ = Q fast path) apply to non-linear circuits and can produce incorrect incremental results.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR promotes algebra capability tags (linear/bilinear/sink/stateful-strict) from plugin-only marker interfaces into first-class Op properties so both internal operators and plugin operators can expose capabilities through a uniform surface.

Changes:

  • Adds IsLinear/IsBilinear/IsSink/IsStatefulStrict properties (default false) to Op and overrides them on selected core operators.
  • Introduces non-generic marker interfaces (ILinearMarker, etc.) and makes the typed plugin capability interfaces inherit them; PluginOperatorAdapter caches marker detection and surfaces it via Op overrides.
  • Adds a new test suite asserting capability flags for internal ops and plugin-adapter detection.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
tests/Tests.FSharp/Tests.FSharp.fsproj Registers the new capability test file in compile order.
tests/Tests.FSharp/Plugin/Capabilities.Tests.fs Adds capability surface tests for internal ops and plugin marker detection.
src/Core/Circuit.fs Adds algebra capability properties to the Op base class.
src/Core/PluginApi.fs Adds non-generic capability markers + wires adapter detection to Op capabilities.
src/Core/Operators.fs Marks several Z-set operators as linear/bilinear via overrides.
src/Core/Fusion.fs Marks fused filter/map operators as linear via overrides.
src/Core/Primitive.fs Marks delay/integrate/differentiate primitives as linear via overrides.
Comments suppressed due to low confidence (3)

src/Core/Circuit.fs:79

  • IsSink doc claims Circuit.Build() rejects downstream operators reading from a sink, but Circuit.Build() currently only performs topological scheduling / cycle detection and does not consult IsSink. Please adjust the docstring to match current behavior (or move this promise to the PR that adds the Build-time validation).
    /// Algebra capability: operator is a *sink* — terminal,
    /// retraction-lossy, may emit a non-Z-set output. Sinks are
    /// excluded from relational composition: `Circuit.Build()` rejects
    /// any operator that reads from a sink's output stream (terminal-
    /// placement enforcement). Bayesian aggregates and external-system
    /// sinks are canonical examples.

src/Core/PluginApi.fs:133

  • The ILinearOperator doc says the scheduler runs LinearLaw at Circuit.Build() “(test-time, via LawRunner.checkLinear)”, but LawRunner explicitly documents itself as a test-time library (not a Build gate) and Circuit.Build() does not run laws today. Please update this docstring to avoid promising Build-time enforcement that doesn’t exist yet.
/// Algebra capability: the operator is *linear* — `op(a + b) =
/// op(a) + op(b)` and `op(0) = 0`. Retraction-native: a
/// negative weight un-accumulates correctly. Declared at the
/// type level so the scheduler can run `LinearLaw` at
/// `Circuit.Build()` (test-time, via `LawRunner.checkLinear`).

src/Core/PluginApi.fs:154

  • ISinkOperator doc claims terminal-placement enforcement happens via a Circuit.Build() validation pass, but Circuit.Build() currently doesn’t check IsSink or enforce sink terminal placement. Please adjust wording to match the current implementation (or defer the statement to the PR that adds the validation).
/// Algebra capability: the operator is a *sink* — terminal,
/// non-Z-set-emitting, potentially retraction-lossy. Sink
/// operators are consciously exempt from relational
/// composition laws and the scheduler enforces terminal
/// placement (a sink may not feed another operator inside a
/// relational path) via the `Circuit.Build()` validation pass.
/// Bayesian aggregates are the canonical example.

Comment thread src/Core/Primitive.fs
Comment on lines +17 to +21
/// Linear: `z⁻¹` is a time-shift; it distributes over addition
/// trivially when `initial = 0` for the group. Callers passing a
/// non-zero initial are responsible for the resulting affine
/// offset — DBSP usage always passes the group zero.
override _.IsLinear = true
Comment thread src/Core/Circuit.fs
Comment on lines +49 to +52
// capabilities through the same surface. The scheduler, fusion
// engine, and incremental-rewriter dispatcher all consult these
// fields — they're load-bearing for capability-aware optimization,
// not decorative.
Comment thread src/Core/PluginApi.fs
Comment on lines +109 to +116
// marker is satisfied automatically via interface inheritance. The
// scheduler / fusion engine / IncrementalAuto dispatcher tests
// against the marker, not the generic.
// ─────────────────────────────────────────────────────────────────────

/// Non-generic marker for `ILinearOperator<_, _>`. Used by
/// `PluginOperatorAdapter` and the scheduler for `:?` runtime tests
/// that don't need exact generic-parameter match.
Comment thread src/Core/Operators.fs
Comment on lines +123 to +124
/// and 0 ⋈ b = a ⋈ 0 = 0. IncrementalAuto rewrites this to the
/// three-term form `Δa ⋈ Δb + z⁻¹(I(a)) ⋈ Δb + Δa ⋈ z⁻¹(I(b))`.
Comment on lines +382 to +388
// This test verifies that the ISinkMarker inheritance correctly
// surfaces through Zeta.Bayesian's `BayesianRateOp`. We can't
// exercise it directly here (Zeta.Bayesian isn't a test-project
// reference and shouldn't be — that'd be a circular-shape
// test), so we rely on the SinkPluginOp above as the structural
// proof. Adding Zeta.Bayesian to this project's references is
// out of scope for PR 1.
AceHack added a commit that referenced this pull request May 21, 2026
…4560)

* feat(core): promote algebra capability tags from plugin-marker interfaces onto Op<'T> base class

PR 1 of an 8-PR campaign that wires the algebra-capability system from
declarative-but-unenforced markers into a load-bearing, uniformly-detected
property surface on every operator (internal + plugin).

## What changes

`Op` base class (Circuit.fs) gains four abstract properties — `IsLinear`,
`IsBilinear`, `IsSink`, `IsStatefulStrict` — each defaulting to `false`.
Concrete operators override only the capabilities they actually have.
Until this change, the algebra tags lived ONLY as plugin marker
interfaces in PluginApi.fs and were ignored by `PluginOperatorAdapter`
(which detected `IStrictOperator`/`IAsyncOperator`/`INestedFixpointParticipant`
but not the algebra markers). That asymmetry meant:

  - Internal operators (MapZSetOp, JoinZSetOp, etc.) had no capability
    surface at all — algebra was implicit-by-code-shape.
  - Plugin operators declared capabilities via marker interfaces but
    `PluginOperatorAdapter` discarded the declarations.
  - Consumers (Incremental.IncrementalJoin, future Fusion/IncrementalAuto)
    had no uniform way to ask "is this operator linear?" without
    custom type tests per call site.

## Non-generic marker pattern

F# generic-interface tests require exact type-parameter match —
`(box plugin) :? IBilinearOperator<obj, obj, 'TOut>` against a concrete
`IBilinearOperator<int, string, decimal>` returns false. The fix is the
BCL `IEnumerable` / `IEnumerable<T>` pattern: a non-generic marker
interface (`ILinearMarker`, `IBilinearMarker`, `ISinkMarker`,
`IStatefulStrictMarker`) for runtime `:?` tests, and the typed interface
inheriting the marker. Plugin authors continue implementing the typed
interface; the marker is satisfied automatically via interface
inheritance.

`PluginOperatorAdapter` now caches one `:?` check per marker at
construction (zero per-tick cost) and surfaces the results through
the new `Op` overrides.

## Internal-operator overrides

| Operator | Capability | Reasoning |
|---|---|---|
| MapZSetOp, FilterZSetOp, FlatMapZSetOp, NegZSetOp | IsLinear=true | Z-set algebra: distributes over addition, op(0)=0 |
| IndexWithOp | IsLinear=true | Indexing distributes over per-key value-group sum |
| JoinZSetOp, CartesianZSetOp, IndexedJoinOp | IsBilinear=true | Weights multiply; per-arg linear; op(0,b)=op(a,0)=0 |
| DelayOp, IntegrateOp, DifferentiateOp | IsLinear=true | Time-shift / running-sum / difference commute with group |
| FilterMapOp, FilterMapOptionalOp | IsLinear=true | Composition of linear ops |
| PlusZSetOp, MinusZSetOp | (default false) | Additive but NOT unary-linear: Plus(0,b)=b≠0 |
| DistinctZSetOp, DistinctIncrementalOp | (default false) | Clamps weights — breaks linearity |
| GroupBySumOp | (default false) | Output keys depend on summed weights, breaks linearity |
| ConstantOp | (default false) | Affine; const_c(0)=c≠0 unless c=0 |

## Tests

21 new tests in `tests/Tests.FSharp/Plugin/Capabilities.Tests.fs`:

  - 15 internal-operator capability tests (one per named op)
  - 5 plugin-marker-detection tests via PluginOperatorAdapter
  - 1 negative test: plain IOperator plugin reports all caps false

All 31 plugin tests pass (10 pre-existing + 21 new); 480 / 481 broader
operator/algebra/circuit tests pass (1 SKIP is pre-existing). Build
clean: 0 warnings, 0 errors on full solution Release build.

## Foundation for PRs 2-8

This is the load-bearing dependency for:

  - PR 2: Circuit.Build() consults IsSink for terminal-placement
    enforcement (the docstring promise that's currently vapor).
  - PR 4: IncrementalAuto dispatcher reads IsLinear/IsBilinear to
    pick Q^Δ=Q vs three-term-bilinear vs D∘Q∘I fallback.
  - PR 5: FusionEngine composes capability tags through DAG rewrite.
  - PRs 6-8: push/morsel/codegen architectures all need uniform
    capability surfacing to dispatch correctly.

No public-API breakage: the marker interfaces still work the same
way for plugin authors; the new Op-base-class properties are
purely additive.

* feat(core): enforce sink-terminality at Circuit.Build() — PR 2 of 8

Makes load-bearing the docstring promise on `ISinkOperator` (PluginApi.fs):
*"the scheduler enforces terminal placement (a sink may not feed another
operator inside a relational path)"* — until this PR, that promise was
vapor.

## Mechanism

After the topological sort succeeds in `Circuit.Build()`, scan every
operator's `Inputs` array. If any input has `IsSink = true`, throw an
`InvalidOperationException` naming both endpoints, their IDs, and a
pointer to the algebra-tag contract. O(N + E) per build; runs once
per circuit lifetime; zero per-tick cost.

The check runs AFTER topological sort so:
  - Operator IDs in the error message are stable (post-Build).
  - Cycle detection (the more structurally-fatal problem) fires first
    when both are present.

## Why this matters

Sinks are retraction-lossy by design — `BayesianRateOp` aggregates state
in a `BetaBernoulli` instance that doesn't un-accumulate when a `-1`
weight arrives. Letting a downstream operator read from a sink would
break the Z-set relational composition laws (associativity over add,
commutativity, distribution through joins). The compile-time
type-checker can catch some cases (when the sink's output type doesn't
match downstream's input type), but generic-typed sinks like
`ISinkOperator<ZSet<int>, ZSet<int>>` would slip through without this
runtime check.

## Tests

9 new tests in `tests/Tests.FSharp/Circuit/SinkTerminality.Tests.fs`:

  Positive (terminal sinks build normally):
    - Sink at terminus, single op upstream
    - Sink consuming a Map output (sink itself at terminus)
    - Multiple independent sinks
    - Non-sink plugin feeding Map (rejection is sink-specific)

  Negative (operators reading from sinks are rejected):
    - Map reading from sink output
    - Filter reading from sink output
    - Plus reading from sink output (multi-input op case)

  Error-message contract:
    - Names both endpoints and their IDs
    - Cites PluginApi.fs:ISinkOperator
    - Explains the algebraic reason ("retraction-lossy")

  Ordering:
    - Cycle detection fires before sink-terminality

All 9 pass. No regressions: 217/218 Circuit/Operators/Plugin tests
pass (1 pre-existing SKIP unchanged). Build clean.

## Dependency

PR 2 depends on PR 1 (#4558) for the `IsSink` property on `Op<'T>`.
Branch is stacked on `feat/op-capability-tags-2026-05-21`; targets
`main` and should auto-rebase cleanly once PR 1 merges.

## Foundation for later PRs

PR 5's FusionEngine must NOT fuse across a sink boundary — the
terminality check at Build time guarantees no sink ever appears
mid-pipeline, simplifying the engine's fusion-pattern rules.
AceHack added a commit that referenced this pull request May 21, 2026
…lter) (#4566)

* feat(core): promote algebra capability tags from plugin-marker interfaces onto Op<'T> base class

PR 1 of an 8-PR campaign that wires the algebra-capability system from
declarative-but-unenforced markers into a load-bearing, uniformly-detected
property surface on every operator (internal + plugin).

## What changes

`Op` base class (Circuit.fs) gains four abstract properties — `IsLinear`,
`IsBilinear`, `IsSink`, `IsStatefulStrict` — each defaulting to `false`.
Concrete operators override only the capabilities they actually have.
Until this change, the algebra tags lived ONLY as plugin marker
interfaces in PluginApi.fs and were ignored by `PluginOperatorAdapter`
(which detected `IStrictOperator`/`IAsyncOperator`/`INestedFixpointParticipant`
but not the algebra markers). That asymmetry meant:

  - Internal operators (MapZSetOp, JoinZSetOp, etc.) had no capability
    surface at all — algebra was implicit-by-code-shape.
  - Plugin operators declared capabilities via marker interfaces but
    `PluginOperatorAdapter` discarded the declarations.
  - Consumers (Incremental.IncrementalJoin, future Fusion/IncrementalAuto)
    had no uniform way to ask "is this operator linear?" without
    custom type tests per call site.

## Non-generic marker pattern

F# generic-interface tests require exact type-parameter match —
`(box plugin) :? IBilinearOperator<obj, obj, 'TOut>` against a concrete
`IBilinearOperator<int, string, decimal>` returns false. The fix is the
BCL `IEnumerable` / `IEnumerable<T>` pattern: a non-generic marker
interface (`ILinearMarker`, `IBilinearMarker`, `ISinkMarker`,
`IStatefulStrictMarker`) for runtime `:?` tests, and the typed interface
inheriting the marker. Plugin authors continue implementing the typed
interface; the marker is satisfied automatically via interface
inheritance.

`PluginOperatorAdapter` now caches one `:?` check per marker at
construction (zero per-tick cost) and surfaces the results through
the new `Op` overrides.

## Internal-operator overrides

| Operator | Capability | Reasoning |
|---|---|---|
| MapZSetOp, FilterZSetOp, FlatMapZSetOp, NegZSetOp | IsLinear=true | Z-set algebra: distributes over addition, op(0)=0 |
| IndexWithOp | IsLinear=true | Indexing distributes over per-key value-group sum |
| JoinZSetOp, CartesianZSetOp, IndexedJoinOp | IsBilinear=true | Weights multiply; per-arg linear; op(0,b)=op(a,0)=0 |
| DelayOp, IntegrateOp, DifferentiateOp | IsLinear=true | Time-shift / running-sum / difference commute with group |
| FilterMapOp, FilterMapOptionalOp | IsLinear=true | Composition of linear ops |
| PlusZSetOp, MinusZSetOp | (default false) | Additive but NOT unary-linear: Plus(0,b)=b≠0 |
| DistinctZSetOp, DistinctIncrementalOp | (default false) | Clamps weights — breaks linearity |
| GroupBySumOp | (default false) | Output keys depend on summed weights, breaks linearity |
| ConstantOp | (default false) | Affine; const_c(0)=c≠0 unless c=0 |

## Tests

21 new tests in `tests/Tests.FSharp/Plugin/Capabilities.Tests.fs`:

  - 15 internal-operator capability tests (one per named op)
  - 5 plugin-marker-detection tests via PluginOperatorAdapter
  - 1 negative test: plain IOperator plugin reports all caps false

All 31 plugin tests pass (10 pre-existing + 21 new); 480 / 481 broader
operator/algebra/circuit tests pass (1 SKIP is pre-existing). Build
clean: 0 warnings, 0 errors on full solution Release build.

## Foundation for PRs 2-8

This is the load-bearing dependency for:

  - PR 2: Circuit.Build() consults IsSink for terminal-placement
    enforcement (the docstring promise that's currently vapor).
  - PR 4: IncrementalAuto dispatcher reads IsLinear/IsBilinear to
    pick Q^Δ=Q vs three-term-bilinear vs D∘Q∘I fallback.
  - PR 5: FusionEngine composes capability tags through DAG rewrite.
  - PRs 6-8: push/morsel/codegen architectures all need uniform
    capability surfacing to dispatch correctly.

No public-API breakage: the marker interfaces still work the same
way for plugin authors; the new Op-base-class properties are
purely additive.

* feat(core): expand Fusion catalog with MapMap / FilterFilter / MapFilter — PR 5 of 8

Adds three new fused operators to `Fusion.fs`, each declaring the
correct `IsLinear` capability tag (from PR 1, #4558). Each fused op
saves one intermediate `ZSet` allocation and one scheduler dispatch
vs the equivalent manual chain.

## New operators

  - `MapMapOp<'A, 'B, 'C>` — `map g ∘ map f` in one pass.
    Function composition is inlined per-entry; output keys may
    collide so sort+consolidate is still required.
    API: `circuit.MapMap(s, f, g)`

  - `FilterFilterOp<'K>` — `filter p₂ ∘ filter p₁` with short-circuit
    on `p₁`. Filter preserves keys + uniqueness so no sort needed.
    API: `circuit.FilterFilter(s, p1, p2)`

  - `MapFilterOp<'A, 'B>` — `filter p ∘ map f` (predicate sees the
    *mapped* value `'B`). Distinct from `FilterMapOp` which is
    `map f ∘ filter p`. Saves intermediate ZSet + the separate
    filter sort pass.
    API: `circuit.MapFilter(s, f, p)`

All three:
  - Override `IsLinear = true` (linear composition of linear ops)
  - Skip the input.IsEmpty fast path correctly
  - Pool-rent + Pool.FreezeSlice for the output buffer
  - Use `ZSetBuilder.sortAndConsolidate` when output keys can collide
    (MapMap + MapFilter); skip the sort when they can't (FilterFilter)

## Tests (10 new in Fusion.Tests.fs, 20/20 total pass)

For each new operator:
  - Basic correctness (specific inputs → expected outputs)
  - Compositional equivalence: fused output == manual chain output
  - IsLinear capability tag verified

Plus MapMap-specific:
  - Colliding output keys consolidate correctly (modulo-based example
    where {1, 2, 3, 4} → {0, 1} via composition)

## What this PR is NOT

This PR ships the *catalog* of fused operators. It does NOT ship a
DAG-rewriter that automatically detects `circuit.Map(circuit.Map(s, f), g)`
and replaces it with `circuit.MapMap(s, f, g)` at `Circuit.Build()`
time. That rewriter would require:

  - Operator-graph mutation (current Circuit has immutable Inputs)
  - Capability composition rules (Linear ∘ Linear = Linear, etc.)
  - Schedule rebuild after fusion

That's an invasive Circuit refactor and ships in its own PR (call it
PR 5.1 / 5.2). The catalog here is the *target* the rewriter would
emit; without the catalog the rewriter would have nowhere to emit
*to*. So this PR is load-bearing for the rewriter's design.

## Dependency

PR 5 depends on PR 1 (#4558) for the `Op.IsLinear` field that the
new operators override. Stacked on `feat/op-capability-tags-2026-05-21`.

## Foundation for later work

  - DAG rewriter (PR 5.1) consumes this catalog
  - PR 6 (push-based) can register push-equivalent variants of these
    fused ops for hot-path sub-circuits
  - PR 8 (codegen) can emit these directly from query expression trees
AceHack added a commit that referenced this pull request May 21, 2026
…push-based + morsel + codegen capstone (#4568)

* backlog(B-0692+B-0693+B-0694): Otto-VSCode 8-PR campaign PRs 6-7-8 — push-based hot-path (IPushOperator + segment-detection) + morsel/span execution (IMorselOperator + cache-sized chunks) + standing-query codegen (IIncrementalGenerator + F# Type Provider) capstone; Aaron-approved shadow* 'file the 3 rows for PRs 6-8'; depends_on chain to PRs 1-5 substrate (#4558/#4560/#4566 merged + #4563/#4564 pending)

* fix(md-lint): MD022/MD032 blanks-around-headings/lists on B-069[234] rows — Phase N subheadings + immediate-bullets need blank lines per markdownlint-cli2

* fix(reviewer-threads): resolve 6 unresolved P1/P2 findings on B-0692/B-0693/B-0694 — (a) move B-0635 + B-0688 from hard depends_on to composes_with per Codex P2 (narrative says PR #1-#5 are the real prereqs; B-0635 wave-particle is conceptual cousin; B-0688 doesn't even exist on main yet so dangling hard-edge); (b) correct Op.fs path references to acknowledge Op<'T> lives in src/Core/Circuit.fs (Copilot P1 — file doesn't exist); (c) mark proposed-new directories in B-0694 Phase 2/3 as TO BE CREATED (Copilot P1 — paths don't exist today)
AceHack added a commit that referenced this pull request May 21, 2026
…oInputs (#4563)

* feat(core): LawRunner.checkBilinear + PluginHarness.runTwoInputs — PR 3 of 8

Closes the algebra-tag verification gap on `IBilinearOperator`. Until
this PR, `LawRunner` had `checkLinear` and `checkRetractionCompleteness`
but no `checkBilinear` — meaning the 3-term incremental-join rewrite in
`Incremental.IncrementalJoin` trusted the bilinear tag without any
test-time way to verify it.

## New surface

### `PluginHarness.runTwoInputs`

Drives a two-input plugin operator through paired input sequences in
lock-step (`Seq.zip` semantics — shorter sequence wins). Two
`HarnessSourceOp` sources at synthetic ids 0+1, adapter at id 2;
mirrors the `runSingleInput` shape so the per-tick publish-counter
discipline is identical.

### `LawRunner.checkBilinear`

Tests three sub-properties per sample:

  L1 — `op(a₁+a₂, b) ≡ op(a₁, b) + op(a₂, b)`   (left-linearity)
  L2 — `op(a, b₁+b₂) ≡ op(a, b₁) + op(a, b₂)`   (right-linearity)
  L3 — `op(-a, b) ≡ -op(a, b)`                   (sign-distribution)

The check generates four independent traces (A₁, A₂, B₁, B₂), runs the
operator through six combinations (A₁B₁, A₂B₁, ASumB₁, A₁B₂, A₁BSum,
ANegB₁), and checks the three laws per tick. Same `(seed,
sampleIndex)`-reproducibility shape as `checkLinear`; same `Result<unit,
LawViolation>` return type.

## Math note on L3 (corrected from initial sketch)

Over an abelian group with standard addition (the case for `int` and
`ZSet<'K>`), L1 + L2 *imply* L3: setting `a₁ = a, a₂ = -a` in L1 gives
`op(0, b) = op(a, b) + op(-a, b)`, so the classical bilinear condition
`op(0, b) = 0` collapses to L3. In that regime L3 is the cleanup law —
an affine offset like `op(a, b) = a*b + c` breaks L1 first (the
constant lands once on LHS, twice on RHS), so the L3-only failure mode
doesn't exist over `int`.

L3 becomes load-bearing — not redundant — when the user supplies a
non-abelian-group `(addOut, negOut)` pair (e.g. a monoid where
`negOut` isn't truly inverse). Checking all three keeps
`checkBilinear` correct across the full range of `'TOut` algebras a
plugin author might supply, not just `int` / `ZSet<_>`.

## Tests (6 new in LawRunner.Tests.fs, 15/15 total pass)

- `BilinearMultOp` (genuine integer multiplication): passes
- `LinearOffsetLiar` (`op(a,b) = (a+b)*2`): catches L1 violation
- `AffineBilinearLiar` (`op(a,b) = a*b + 7`): catches L1 (the L3
  failure that I'd originally claimed was unique to this fixture
  doesn't exist over `int`; the docstring + test docstring now
  explain the math)
- Reproducibility on same seed
- Bad-args validation (samples and scheduleLength)
- `runTwoInputs` lock-step behavior + truncation to shorter input

15/15 LawRunner tests pass, no regressions in broader plugin tests.
Build clean.

## Independence

PR 3 does NOT depend on PR 1 (#4558) or PR 2 (#4560) — `checkBilinear`
verifies bilinearity by trace-comparison, not by reading `Op.IsBilinear`.
This means PR 3 can ship orthogonally; targets `main` directly.

## Foundation for later PRs

PR 4's `IncrementalAuto` dispatcher will read `Op.IsBilinear` (from PR
1) to pick the three-term-bilinear rewrite. A debug-mode build hook
(planned for PR 4 or later) can run `checkBilinear` against any
operator claiming the tag, catching tag-vs-implementation drift early.

* fix(PR #4563 review threads): AfterStepAsync hook in PluginHarness + docstring math corrections

Addresses 3 reviewer findings on PR #4563:

1. **Codex P1** (PluginHarness.fs): `runTwoInputs` (and `runSingleInput`,
   pre-existing same gap) advanced plugin operators with `StepAsync` but
   never called `AfterStepAsync` — so any plugin implementing
   `IStrictOperator` exercised semantics differing from
   `Circuit.Step/StepAsync`, with strict state never committing between
   ticks. `LawRunner.checkBilinear` on strict ops would silently validate
   against incorrect state. **Fix**: mirror the Circuit's post-step
   hook — call `AfterStepAsync` after each tick's `StepAsync` completes.
   Same fix applied to both `runSingleInput` and `runTwoInputs` (pattern
   parity).

2. **Copilot** (LawRunner.fs:158): docstring described the
   `(addOut, negOut)` pair as a "monoid" with non-inverse `negOut` —
   monoids by definition lack inverses, so the example was malformed.
   **Fix**: rephrase to "caller-supplied pair that doesn't actually form
   an abelian group" (negOut might not be a true inverse; operations
   might not be associative/commutative; hidden state might creep in).
   Same point, correct algebra.

3. **Copilot** (LawRunner.Tests.fs:218 + 248): the comment block claimed
   *"The interesting failure case is L3: a plugin can satisfy L1+L2 while
   smuggling an additive offset (op(a,b) = a*b + c)"* — but over the
   abelian-group output type (`int`), any constant offset breaks L1
   first (constant lands once on LHS, twice on RHS). The framing was
   mathematically incorrect. **Fix**: reword both the bilinearity-
   fixtures intro comment AND the `AffineBilinearLiar` fixture docstring
   to reflect that L1 trips first over abelian groups; L3 is the
   load-bearing law only over non-abelian-group output types. Aligns
   with the math note already in `LawRunner.checkBilinear`'s docstring.

## Tests

18/18 LawRunner + Harness tests pass after fixes. Build clean. No
behavioral changes to test outputs — the AfterStepAsync addition is
load-bearing for strict ops but no existing test exercises a strict
bilinear op (BilinearMultOp / LinearOffsetLiar / AffineBilinearLiar
are all non-strict).

## Review-thread resolution

These 3 threads on #4563 close with this commit. Remaining unresolved
state on #4563 (Stryker.NET F# unsupported error) is workflow-side and
addressed by Otto-CLI's PR #4570 + #4571 retarget work.
AceHack added a commit that referenced this pull request May 21, 2026
…re-land of #4564) (#4567)

* feat(core): IncrementalAuto capability-aware dispatcher — PR 4 of 8

Adds `Circuit.IncrementalAuto<'K>(q, input)`, a dispatcher that picks
the right incrementalization based on the algebra capability tag on
`q`'s resulting operator:

  - `IsLinear  = true`  →  `Q^Δ = Q` (deltas pass through unchanged)
  - `IsSink    = true`  →  throws (sinks are terminal, can't incrementalize)
  - otherwise           →  fall back to `D ∘ Q ∘ I` via existing
                           `IncrementalizeZSet`

This makes the algebra-capability system load-bearing on the
incremental-rewrite side — the DBSP paper's central claim "linear ops
incrementalize trivially; bilinear ops use the three-term form; rest
fall back to D∘Q∘I" finally has a dispatcher that mechanizes the first
and third clauses. (The bilinear case has its own richer signature in
`IncrementalJoin`; a future `IncrementalAutoJoin` dispatcher for
bilinear ops can layer on top of that.)

The dispatcher probes `q` by applying to the input directly, then
inspects `Op.IsLinear` / `Op.IsSink` (from PR 1, #4558). The probe
side-effect registers the operator in the circuit; for the
linear-passthrough path this IS the correct wiring. For the
non-linear fallback path, the probed op is orphan (no consumers) — a
small per-tick cost; pruning unreachable operators at `Circuit.Build()`
is a future improvement.

  - `IncrementalAuto with linear Map produces same delta stream as
    direct Q` — operational correctness check over 4 ticks (insert,
    insert, retract, empty)
  - `IncrementalAuto with non-linear Distinct falls back to D-Q-I` —
    output matches `IncrementalizeZSet` over a 6-tick scenario with
    duplicates and retractions
  - `IncrementalAuto throws when the operator is a sink` — error
    message contains "IncrementalAuto", "sink", and the operator name
  - `IncrementalAuto with linear op adds exactly one operator` —
    structural check that the fast path was taken (just the Map; no I
    or D)
  - `IncrementalAuto with non-linear op adds four operators` —
    structural check that fallback registered probe + I + new Q + D
    (probe is the documented orphan)

No regressions in broader Circuit tests. Build clean.

PR 4 depends on PR 1 (#4558) for `Op.IsLinear` and `Op.IsSink`.
Stacked on `feat/op-capability-tags-2026-05-21`; targets `main`.

PR 5's FusionEngine will use the same `IsLinear`/`IsBilinear` reads
to compose capability tags through fusion. PR 8's standing-query
codegen can generate the `IncrementalAuto` decision tree at compile
time rather than at runtime probe, eliminating the orphan-operator
cost entirely.

* fix(PR #4567 review threads): probe-side-effect docs + test naming + dead-code cleanup

Addresses 5 reviewer findings on PR #4567:

1. **Codex P1** (Incremental.fs IncrementalAuto): probe `q.Invoke input`
   runs *before* dispatch decides path, so side-effecting builders
   (e.g. `AdvancedExtensions.Inspect`) execute unexpected work even
   on sink/fallback paths. Sink case throws *after* registering the
   probe op, leaving an orphan sink in the circuit if the caller
   catches + continues. **Fix**: prominent ⚠️ side-effect warning
   section in docstring naming both implications; sink-throw error
   message also calls out the orphan-sink left behind. Architectural
   fix (non-registering probe path) is non-trivial and deferred —
   acknowledged in the new "future work" line. The docstring now
   makes the side-effect explicit so callers can avoid the failure
   mode (use non-side-effecting `q`; don't catch+continue on a
   sink-rejection).

2. **Copilot** (IncrementalAuto.Tests.fs:39): `feedAndStep` helper
   was dead code (left after refactoring out the test that used it).
   **Fix**: removed.

3. **Copilot** (IncrementalAuto.Tests.fs:176): test name said "adds
   three operators" but assertion + comment expected 4.
   **Fix**: renamed to "adds four operators (probe-orphan + Integrate
   + new Q + Differentiate)" matching the assertion.

4. **Copilot** (IncrementalAuto.Tests.fs:142): comment block claimed
   "output stream is literally the probed op (same reference)" and
   "zero-allocation" — but the test only asserts operator-count delta,
   not reference identity. **Fix**: rephrase comment to match what's
   actually tested (dispatch path verified via operator-count delta;
   reference-equality assertion would need internal `Stream.Op`
   accessor exposed).

## Tests

5/5 IncrementalAuto tests pass after fixes. Build clean. No
behavioral changes to operator dispatch — the test rename + comment
edits + dead-code removal are all annotation-shape; the docstring
side-effect warnings are documentation-only. The orphan-sink + probe-
side-effect *behavior* is unchanged; the *visibility* of that
behavior is improved per Codex's P1.

## Architectural follow-up not in scope

The cleanest fix for both Codex's P1 (probe side effects) AND the
sink-rejection orphan-sink issue would be a non-registering probe
path — e.g., a `Circuit.PreviewOp(q, input)` that runs `q.Invoke`
inside a transaction-shaped scope that rolls back registration if
the dispatcher rejects. That requires changes to the `Op` / `Circuit`
registration contract (today's `Register` doesn't support rollback)
and is deferred to a future PR. Worth filing as a backlog row when
the architectural cost vs. payoff is clearer.

* fix(PR #4567 Codex P1 #2): IncrementalAuto must check whole-chain linearity, not just terminal op

Codex's second P1 finding caught a real correctness bug I missed: the
dispatcher was checking `probedOutput.Op.IsLinear`, but `probedOutput.Op`
is only the terminal operator in the chain produced by `q`. A query like
`q(s) = Map(Distinct(s))` ends on a linear Map but the *composed* query
is non-linear; the `Q^Δ = Q` rewrite produces wrong incremental results.

## Fix

Recursive chain check: walk `Inputs` back from the probed terminal op
to the original `input.Op`. If every op in the chain is linear AND we
reach `input.Op` only through linear ops, the chain is linear. Otherwise
fall back to `D ∘ Q ∘ I`. Multi-input ops (Plus, Minus, default
IsLinear=false) correctly route to the fallback path via this check.

```fsharp
let rec isLinearChainToInput (op: Op) (inputOp: Op) : bool =
    if System.Object.ReferenceEquals(op, inputOp) then true
    elif op.Inputs.Length = 0 then false   // source op that isn't the input
    elif not op.IsLinear then false
    else op.Inputs |> Array.forall (fun dep -> isLinearChainToInput dep inputOp)
```

## Regression test added

`IncrementalAuto with terminal-linear-but-inner-non-linear chain falls back (Map ∘ Distinct)` —
exercises the exact failure mode Codex flagged. Builds Map(Distinct(s)),
runs IncrementalAuto, compares per-tick output to IncrementalizeZSet
(D∘Q∘I reference). The test exercises duplicate-insertion + retraction
scenarios where the broken dispatcher would produce wrong output (Map
of every delta directly, ignoring Distinct's cumulative-state clamping).

Before this fix: subject would emit `Map(delta)` directly each tick →
incorrect when delta has duplicates that Distinct clamps.
After this fix: subject falls back to D∘Q∘I → matches reference.

## Verification

6/6 IncrementalAuto tests pass (5 pre-existing + 1 new regression).
Build clean. The chain-check helper is O(N) in chain depth, called
once at dispatch time; zero per-tick cost.

## Related

Composes with the earlier fix in this PR for the other 5 review threads
(8230bed) — docstring side-effect warnings, test rename, dead-code
removal, comment rewording. This commit addresses the substantive
correctness P1 that the earlier docstring-shape fixes left unaddressed.
AceHack added a commit that referenced this pull request May 21, 2026
…re-land of #4564) (#4567)

* feat(core): IncrementalAuto capability-aware dispatcher — PR 4 of 8

Adds `Circuit.IncrementalAuto<'K>(q, input)`, a dispatcher that picks
the right incrementalization based on the algebra capability tag on
`q`'s resulting operator:

  - `IsLinear  = true`  →  `Q^Δ = Q` (deltas pass through unchanged)
  - `IsSink    = true`  →  throws (sinks are terminal, can't incrementalize)
  - otherwise           →  fall back to `D ∘ Q ∘ I` via existing
                           `IncrementalizeZSet`

This makes the algebra-capability system load-bearing on the
incremental-rewrite side — the DBSP paper's central claim "linear ops
incrementalize trivially; bilinear ops use the three-term form; rest
fall back to D∘Q∘I" finally has a dispatcher that mechanizes the first
and third clauses. (The bilinear case has its own richer signature in
`IncrementalJoin`; a future `IncrementalAutoJoin` dispatcher for
bilinear ops can layer on top of that.)

The dispatcher probes `q` by applying to the input directly, then
inspects `Op.IsLinear` / `Op.IsSink` (from PR 1, #4558). The probe
side-effect registers the operator in the circuit; for the
linear-passthrough path this IS the correct wiring. For the
non-linear fallback path, the probed op is orphan (no consumers) — a
small per-tick cost; pruning unreachable operators at `Circuit.Build()`
is a future improvement.

  - `IncrementalAuto with linear Map produces same delta stream as
    direct Q` — operational correctness check over 4 ticks (insert,
    insert, retract, empty)
  - `IncrementalAuto with non-linear Distinct falls back to D-Q-I` —
    output matches `IncrementalizeZSet` over a 6-tick scenario with
    duplicates and retractions
  - `IncrementalAuto throws when the operator is a sink` — error
    message contains "IncrementalAuto", "sink", and the operator name
  - `IncrementalAuto with linear op adds exactly one operator` —
    structural check that the fast path was taken (just the Map; no I
    or D)
  - `IncrementalAuto with non-linear op adds four operators` —
    structural check that fallback registered probe + I + new Q + D
    (probe is the documented orphan)

No regressions in broader Circuit tests. Build clean.

PR 4 depends on PR 1 (#4558) for `Op.IsLinear` and `Op.IsSink`.
Stacked on `feat/op-capability-tags-2026-05-21`; targets `main`.

PR 5's FusionEngine will use the same `IsLinear`/`IsBilinear` reads
to compose capability tags through fusion. PR 8's standing-query
codegen can generate the `IncrementalAuto` decision tree at compile
time rather than at runtime probe, eliminating the orphan-operator
cost entirely.

* fix(PR #4567 review threads): probe-side-effect docs + test naming + dead-code cleanup

Addresses 5 reviewer findings on PR #4567:

1. **Codex P1** (Incremental.fs IncrementalAuto): probe `q.Invoke input`
   runs *before* dispatch decides path, so side-effecting builders
   (e.g. `AdvancedExtensions.Inspect`) execute unexpected work even
   on sink/fallback paths. Sink case throws *after* registering the
   probe op, leaving an orphan sink in the circuit if the caller
   catches + continues. **Fix**: prominent ⚠️ side-effect warning
   section in docstring naming both implications; sink-throw error
   message also calls out the orphan-sink left behind. Architectural
   fix (non-registering probe path) is non-trivial and deferred —
   acknowledged in the new "future work" line. The docstring now
   makes the side-effect explicit so callers can avoid the failure
   mode (use non-side-effecting `q`; don't catch+continue on a
   sink-rejection).

2. **Copilot** (IncrementalAuto.Tests.fs:39): `feedAndStep` helper
   was dead code (left after refactoring out the test that used it).
   **Fix**: removed.

3. **Copilot** (IncrementalAuto.Tests.fs:176): test name said "adds
   three operators" but assertion + comment expected 4.
   **Fix**: renamed to "adds four operators (probe-orphan + Integrate
   + new Q + Differentiate)" matching the assertion.

4. **Copilot** (IncrementalAuto.Tests.fs:142): comment block claimed
   "output stream is literally the probed op (same reference)" and
   "zero-allocation" — but the test only asserts operator-count delta,
   not reference identity. **Fix**: rephrase comment to match what's
   actually tested (dispatch path verified via operator-count delta;
   reference-equality assertion would need internal `Stream.Op`
   accessor exposed).

## Tests

5/5 IncrementalAuto tests pass after fixes. Build clean. No
behavioral changes to operator dispatch — the test rename + comment
edits + dead-code removal are all annotation-shape; the docstring
side-effect warnings are documentation-only. The orphan-sink + probe-
side-effect *behavior* is unchanged; the *visibility* of that
behavior is improved per Codex's P1.

## Architectural follow-up not in scope

The cleanest fix for both Codex's P1 (probe side effects) AND the
sink-rejection orphan-sink issue would be a non-registering probe
path — e.g., a `Circuit.PreviewOp(q, input)` that runs `q.Invoke`
inside a transaction-shaped scope that rolls back registration if
the dispatcher rejects. That requires changes to the `Op` / `Circuit`
registration contract (today's `Register` doesn't support rollback)
and is deferred to a future PR. Worth filing as a backlog row when
the architectural cost vs. payoff is clearer.

* fix(PR #4567 Codex P1 #2): IncrementalAuto must check whole-chain linearity, not just terminal op

Codex's second P1 finding caught a real correctness bug I missed: the
dispatcher was checking `probedOutput.Op.IsLinear`, but `probedOutput.Op`
is only the terminal operator in the chain produced by `q`. A query like
`q(s) = Map(Distinct(s))` ends on a linear Map but the *composed* query
is non-linear; the `Q^Δ = Q` rewrite produces wrong incremental results.

## Fix

Recursive chain check: walk `Inputs` back from the probed terminal op
to the original `input.Op`. If every op in the chain is linear AND we
reach `input.Op` only through linear ops, the chain is linear. Otherwise
fall back to `D ∘ Q ∘ I`. Multi-input ops (Plus, Minus, default
IsLinear=false) correctly route to the fallback path via this check.

```fsharp
let rec isLinearChainToInput (op: Op) (inputOp: Op) : bool =
    if System.Object.ReferenceEquals(op, inputOp) then true
    elif op.Inputs.Length = 0 then false   // source op that isn't the input
    elif not op.IsLinear then false
    else op.Inputs |> Array.forall (fun dep -> isLinearChainToInput dep inputOp)
```

## Regression test added

`IncrementalAuto with terminal-linear-but-inner-non-linear chain falls back (Map ∘ Distinct)` —
exercises the exact failure mode Codex flagged. Builds Map(Distinct(s)),
runs IncrementalAuto, compares per-tick output to IncrementalizeZSet
(D∘Q∘I reference). The test exercises duplicate-insertion + retraction
scenarios where the broken dispatcher would produce wrong output (Map
of every delta directly, ignoring Distinct's cumulative-state clamping).

Before this fix: subject would emit `Map(delta)` directly each tick →
incorrect when delta has duplicates that Distinct clamps.
After this fix: subject falls back to D∘Q∘I → matches reference.

## Verification

6/6 IncrementalAuto tests pass (5 pre-existing + 1 new regression).
Build clean. The chain-check helper is O(N) in chain depth, called
once at dispatch time; zero per-tick cost.

## Related

Composes with the earlier fix in this PR for the other 5 review threads
(8230bed) — docstring side-effect warnings, test rename, dead-code
removal, comment rewording. This commit addresses the substantive
correctness P1 that the earlier docstring-shape fixes left unaddressed.
AceHack added a commit that referenced this pull request May 21, 2026
…rdination of load-bearing-substrate changes (#4575)

Mechanizes the human-as-coordination-substrate pattern Aaron explicitly
named 2026-05-21 ("i'm here right now" — for now ferrying load-bearing-
substrate-change notifications between AI surfaces; trajectory is bus-
based mechanization).

## The gap this row addresses

When one AI surface lands a load-bearing substrate change — capability
tags on `Op<'T>` (PR #4558), `IncrementalAuto`'s chain-walk logic
(#4567), new files in `.claude/rules/`, new computation expressions —
other AI surfaces working in adjacent substrate need to inherit the
change for their next session. Today: Aaron ferries. Cluster-scale
(10-20 surfaces per Aaron's $100k cluster expansion 2026-05-21):
human-ferry breaks empirically.

## The mechanism

New bus topic `substrate-surface-change` (extends `tools/bus/`):

  - **Publish discipline**: after any PR landing that modifies load-
    bearing surfaces, publishing AI calls `bun tools/bus/publish.ts
    --topic substrate-surface-change --from <sender-id> --payload <json>`.
  - **Subscribe discipline (cold-boot)**: AI bootstreams extend to
    include `bun tools/bus/list.ts --topic substrate-surface-change
    --since 24h` — recent envelopes show "what load-bearing substrate
    changed in the last 24h."
  - **Retention**: 7d default; expired envelopes fall back to auto-
    loaded rules + commit history. The envelope is the *cache* of
    recent changes; the *truth* is the substrate itself.

## What this row does NOT do

- Does NOT replace auto-loaded `.claude/rules/` inheritance (that
  stays the durable substrate)
- Does NOT replace claim-acquire-before-worktree-work (that stays
  the per-row collision prevention)
- Does NOT replace Knights Guild / KSK (that stays the policy gate)

It complements all three by adding the **recent-changes-cache** layer
that closes the "I just shipped X; how do other surfaces find out
before their next session?" gap.

## Composition with broader trajectory

- B-0400 — bus protocol substrate this row extends
- B-0689 — Otto-VSCode SENDER_IDS pattern this row leans on for `from` field
- B-0695 — fast/life-branch experiment; sibling coordination-cost-reduction
- Algebra-campaign PRs (#4558/#4560/#4563/#4566/#4567) — substrate-surface changes that would have benefited from this envelope pattern

## Substrate-honest framing on the file itself

Filed per Aaron's explicit "feel free we can'thave too much backlog in
my opinion the infinate backlog win when labor=0" framing, applying the
`largest-mechanizable-backlog-wins.md` discipline. Recalibrated from
earlier "I won't file unilaterally" reasoning — that was a misapplication
of the row-collision lesson (which was about coordination, not about
backlog overhead).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants