Skip to content

feat(ilp): witness-level mask/abstain channel per the #155 field contract - #157

Merged
niveousdragon merged 8 commits into
mainfrom
feat/witness-mask-channel
Jul 19, 2026
Merged

feat(ilp): witness-level mask/abstain channel per the #155 field contract#157
niveousdragon merged 8 commits into
mainfrom
feat/witness-mask-channel

Conversation

@niveousdragon

Copy link
Copy Markdown
Contributor

The problem

The engine-mode credit has no per-witness epistemic state: a witness the system cannot evaluate scores identically to one it evaluated as false. Under noisy-OR the two are numerically indistinguishable (a factor of 1−p ≈ 1 versus exclusion), so no float encoding can carry the difference — and the downstream consumer's semantics (issue #155, Belnap-routed ledgers) is load-bearing on exactly that distinction: masked ≠ false, credit exactly 0.0 iff MASKED, never a coerced label.

The slice this branch implements

A witness-level mask channel per the #155 field contract, on the Python surface (the kernel-phase state channel is scoped separately):

  • witness_mask: bool Tensor [num_events, n_labels] (True = MASKED) accepted by enumerate_specs, train_engine_mode, kfold_select, frozen_select. Default None — byte-identical behavior, pinned by test. Masked flat rows are physically excluded from the noisy-OR index: exactly-zero credit and exactly-zero gradient (pinned: grad == 0.0 on the masked row, nonzero on an active one). Mask lookups are bounds-guarded — an out-of-range engine constant stays the same typed refusal as the no-mask path, and negative constants cannot alias.
  • Interval-aware selection. A fact of a neural candidate with ≥1 masked witness and OR_active < 0.5 is uncertain — the interval [OR_active, 1] crosses the decision threshold — so it is excluded from holdout accuracy (neither for nor against: masked is never coerced to false) and counted against per-candidate coverage; OR_active ≥ 0.5 stays certain-true by OR monotonicity. A coverage gate (min_coverage=0.5) drops low-coverage candidates before the fit gate with a named abstention; HoldoutSelection gains a coverage dict. Relational candidates carry no mask state (masked_any=None) — "not neural's domain" stays distinct from "neural abstained".
  • Training threads the mask and reports EngineModeResult.masked_facts; the NLL trains on the lower bound OR_active (masked rows contribute no loss term; full interval-aware training is a kernel-phase item — documented in the docstrings, not implied away).
  • KC2 mirror. The consumer's kill-criterion 2 fixture (coerce_abstain_to_false) is mirrored as a test: coercing the same rows' scores to false gates the true rule out, masking selects it — the decisions diverge (asserted on rule, not on dataclass fields). The test's discrimination was proven both ways: sabotaging the certain/uncertain logic turns it red.

Measured on GPU (A40; logs archived)

  • Full engine-mode suite 45 passed (incl. all mask-channel tests and the pre-existing CUDA gates — kill-criterion, Occam, acceptance with the arity-2 head — untouched numbers, default path byte-identical); identifiability suite 16 passed.
  • Scale probe for Phase-2 kernel scoping (this branch's surface, unmasked baseline): at 10⁴ events the torch credit math is not the wall (median step 57 ms, peak 66 MB GPU) — engine program compilation is (20 s at 10³ → 249 s at 10⁴; at 10⁵ compile alone exceeded a 40-minute budget; kfold_select recompiles per fold, multiplying it: 1371 s wall at 10⁴, ~75% compilation). Numbers feed the kernel scoping doc; they also say the honest next optimization is fact ingestion, not the credit arithmetic.

Notes for review

…ants stay a typed refusal

witness_mask[z, y] indexed the mask with no bounds check: an out-of-range
engine constant z (>= mask rows) crashed with a bare IndexError when a mask
was supplied, instead of the typed ValueError the no-mask path gets from
prepare_extension's bounds check and the dense-identity law. Worse, a
negative z silently aliased to the mask's last row via Python negative
indexing.

Add a _mask_masks(z, y) guard used by both the filtering comprehension and
the masked_any computation: the mask is only consulted for 0 <= z <
witness_mask.shape[0], so an out-of-range z is left in the witness list for
the existing downstream typed checks to refuse, exactly as on the no-mask
path.

Add a red-first regression test for the out-of-range crash, plus a test for
the distinct-num_rows ValueError naming both relations.
…ge gate

Threads witness_mask/min_coverage through frozen_select and kfold_select's
held-out scoring: a masked fact's truth is now an interval, not a coerced
False. A neural fact is CERTAIN iff predicted-true (OR_active >= 0.5, which
stays true under any completion of the masked witnesses by monotonicity) or
it was never masked at all; the rest are uncertain, excluded from a
candidate's accuracy and tallied into a per-candidate coverage fraction.
HoldoutSelection carries that fraction; _select_from_holdout gates candidates
below min_coverage before the fit gate, with an abstention reason that stays
traceable to masking even when a coverage-thinned pool goes on to fail fit.
…ounting

train_engine_mode(..., witness_mask=None) now passes the mask straight into
enumerate_specs, so a masked witness is excluded from the training index too
(zero credit, zero gradient) -- the training NLL is a lower bound over the
OR's active witnesses only, the documented Python-phase boundary; the full
masked interval stays a selection-time concern in frozen_select/kfold_select.

EngineModeResult gains masked_facts: {(left, right): count of facts with >=1
masked witness}, neural specs only, zero-count entries omitted.

kfold_select now threads the same global witness_mask tensor into each
fold's train_engine_mode call as well as the held-out enumerate_specs call
(previously training-only untouched, per its own docstring) -- one mask per
(event, label) row space, reused unchanged across folds since per-fold fact
subsets already vary the witness lists.

The new gradient test (masked row's gradient is exactly 0.0) is green
immediately: Task 1's index-build exclusion already guarantees it, so it
pins the exact-zero gradient guarantee from the index-build exclusion rather
than starting red.
The masked != coerced inequality was tautological: coverage is non-empty
whenever a mask is supplied, so the dataclasses differ unconditionally.
Assert the selections themselves diverge (true rule selected under masking,
abstention under false-coercion) -- the discrimination the mirror's name
claims.
@niveousdragon
niveousdragon requested a review from levi770 as a code owner July 18, 2026 16:26
Comment thread python/tests/test_neural_credit.py Fixed
@levi770

levi770 commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

@niveousdragon

Dual independent review (same two-reviewer channel as #152/#154/#156): an engine/conformance pass (inline, full diff) and a numerical/KC2-fidelity pass (by execution — base-vs-head suites, 12 adversarial probes, a KC2-mirror mutation test). Verdict: no blocking defects; the #155 / §3b field contract holds verbatim. LGTM.

What we verified holds (both passes converge)

  • masked ≠ false, physically. Masked witnesses are removed from the noisy-OR index at build time — exactly-zero credit and exactly-zero gradient, never a coerced-false cover. Probed stronger than the grad test: the OR is byte-invariant to any probability on a masked row (0.01 vs 0.99 → bit-equal) — physical absence from the index, not "gradient zero at a point."
  • Two epistemic zeros stay distinct. A fact with all witnesses masked (OR=0, masked_any=True → uncertain) does not collapse into a fact with no witnesses at all (OR=0, masked_any=False → certain-false). This is the is_true-laundering class the downstream Belnap semantics is load-bearing on; correctly avoided.
  • Interval logic is sound (noisy-OR monotonicity). OR_active ≥ 0.5 is certain-true under any completion of the masked witnesses, so a masked positive stays counted and a masked negative with OR_active ≥ 0.5 stays counted against the candidate (coverage 1.0) — masking can't hide a miss behind "uncertainty." OR_active < 0.5 is honestly uncertain: excluded from accuracy, counted against coverage. Zero-certain → 0.0 (NaN-guarded, so select_rule comparisons stay valid).
  • The bounds-guard is exactly right. _mask_masks does not preempt the downstream typed refusals: an out-of-range witness constant returns the same typed ValueError as the no-mask path (not a bare IndexError), and negatives can't alias to the mask's last row (0 <= z). The no-mask path's fail-closed semantics survive untouched.
  • Backward-compat is contract, proven by execution. base (0b41dae) vs head (e7d4da5) suite fail-sets are byte-identical (the 5 fails are pyxlog._native unavailable on a source-only run; your A40 run covers them at 45 pass); the +11 pass are exactly the new mask tests. Every new parameter defaults to None → pre-mask behavior, pinned.
  • KC2-mirror has teeth. Independently mutation-tested: replacing the certain/uncertain logic with "all certain" (silent coercion) in both frozen_select and kfold_select turns the KC2-mirror and the uncertain-fact test red; clean on revert. "Sabotage turns it red" confirmed.
  • Coverage gate + traceability. coverage == min_coverage (0.50 exactly) holds (strict <); the coverage-drop reason is named distinctly from a fit-fail, so an abstention stays traceable to masking rather than reading as a bare "nothing fit."

Non-blocking notes (none blocks merge)

  1. _mask_masks does a per-witness Python-scalar read bool(witness_mask[z, y]); on a CUDA mask that's a device-sync per witness at build time. It doesn't burn at these numbers (your scale probe shows engine-program compilation dominates, not the credit math), but a batched mask lookup is worth it when the mask moves to the kernel-phase state channel.
  2. The accuracy denominator is n_certain, so a candidate just above the coverage gate is judged on a small certain subset — a conscious tradeoff (the coverage gate bounds the extreme). Flagging for visibility, not a defect.
  3. Scope / what's still open for our bridge: feat(ilp): witness-level mask/abstain channel per the #155 field contract #157 delivers issue Engine-mode follow-ups: witness-level mask schema, registration-seam delta (arity/sorts/artifact hashes), frozen entry point #155 ask feat(ilp): Tensorized Differentiable ILP engine #1 (mask channel) cleanly; ask v0.4.0-beta → GA: Deferred Items #3 (frozen entry point) was feat(ilp): typed registry for neural relations and a frozen-detector entry point #156. Ask feat(ilp): GA hardening gates and documentation alignment #2 — the registration-seam delta (arity / arg_sorts / artifact_hashes on register_network) — is still open, and our acceptance bridge's registration-mapping depends on it. Separately, the mask channel is single-row-space by design: it fail-closed refuses (with a named error) if two registered neural relations disagree on num_rows — fine for a single neural predicate, a boundary to note for heterogeneous multi-relation programs.

Test discipline is excellent — 11 tests cover every property above, including the two seam edges (out-of-range-under-mask typed refusal; multi-relation num_rows disagreement named). Nice work.

The list was collected and never read; asserting the mask actually reached a
neural spec keeps the exact-zero-gradient assertions from passing vacuously.
@niveousdragon

Copy link
Copy Markdown
Contributor Author

Responses to the three non-blocking notes (the bot's unused-local inline is also closed — 007b77e turns the collected neural-spec list into a mask-landed precondition guard, so the zero-gradient assertions cannot pass vacuously):

1 (per-witness device-sync on CUDA masks) — agreed, and it lands in the kernel scoping. At index-build time on today's scales it is invisible next to program compilation (your read of the scale probe matches ours), and the honest fix is not a Python micro-batch but the kernel-phase state channel itself, where the mask is a device-resident array and the build-time lookup disappears. Recorded as a constraint on the (float value, witness state) layout.

2 (accuracy judged on the certain subset near the coverage gate) — conscious tradeoff, now with the tradeoff stated. The alternative — blending uncertain facts into the denominator — would smuggle masked rows back into the score with an arbitrary weight, which is the coercion class KC2 exists to kill. The coverage gate is the bound; a stricter caller raises min_coverage. Worth revisiting when the kernel carries intervals end-to-end and the score itself can be an interval.

3 (ask #2 still open) — acknowledged, and it moves up our queue. The registration-seam delta (arity / arg_sorts / artifact_hashes on register_network, neural.rs:178) is packaged as W3 in our Phase-2 kernel scoping; your note that the acceptance bridge's registration-mapping depends on it promotes it from "after the credit kernel" to a candidate next slice — it is small, Rust-only, no CUDA, and the Python-side NeuralRelationSpec from #156 already models the shape. The single-row-space boundary of the mask channel (named refusal on heterogeneous num_rows) is likewise noted in scoping: multi-relation row spaces become representable exactly when the kernel state channel is per-relation.

The byte-invariance probe (0.01 vs 0.99 on a masked row → bit-equal OR) is a stronger statement of the exact-zero property than our gradient pin — we are adopting it into the suite in the next slice.

@niveousdragon
niveousdragon merged commit 6e593de into main Jul 19, 2026
16 checks passed
@niveousdragon
niveousdragon deleted the feat/witness-mask-channel branch July 19, 2026 18:34
levi770 pushed a commit that referenced this pull request Jul 20, 2026
…ed against the program, opaque artifact identity, queryable network_metadata (#160)

* feat(neural): registration metadata on register_network — arity validated against the declarations, sorts and artifact hash carried opaquely

* feat(neural): network_metadata getter — the typed registry, queryable

Add CompiledProgram.network_metadata(name), modeled on
neural_predicate_info: returns the registration-time arity/arg_sorts/
artifact_hash plus every nn/4 declaration bound to the network name
(predicate, predicate_arity, input_arity, labels), so a consumer can
check its own idea of a network's shape against the program's actual
declarations rather than a naming convention. Declared-but-unregistered
and undeclared names get typed PyValueErrors.

This metadata was previously write-only: register_network stored it on
NetworkConfig, but NetworkHandle::from_config never copied it over, so
it was dropped the instant registration completed. Extend NetworkHandle
with the same three fields, populated in from_config, so there is
something to read back.

* fix(neural): network_metadata names the honest boundary for embedding-declared networks

An embedding-declared network registers via register_embedding(), not
register_network(); calling register_network() on it hits the existing
cross-registration guard. But network_metadata's "not registered" error
told the caller to "call register_network() first" regardless — wrong
for embeddings, misdirecting them into a call that will always fail.

Branch on declared_network_forms (the same map the register_network
guard at line 218 already consults) before the generic "not registered"
error: if the name is embedding-declared, say so explicitly and name
register_embedding() as the actual registration path, and state that
arity/arg_sorts/artifact_hash metadata is out of scope for embeddings
by design. Keep the existing message for the genuine
classification-declared-but-unregistered case. Doc comment now lists
all three failure modes: undeclared / embedding-declared /
declared-but-unregistered.

* test(neural): registration-metadata surface — roundtrip, typed refusals, getter boundaries, OR byte-invariance probe

TestRegistrationMetadata in test_network_registry.py (CUDA-gated, class
skipif since the file has no prior CUDA gate) covers register_network's
arity/arg_sorts/artifact_hash kwargs and the network_metadata() getter:
roundtrip, byte-compat with the legacy no-kwargs call, the three typed
refusals (arity vs. declaration, arg_sorts without arity, length
mismatch), and the getter's three failure modes (undeclared, declared-
but-unregistered, embedding-declared).

Writing these tests surfaced that _compiled_program_register_network_with_lineage
in crates/pyxlog/python/pyxlog/__init__.py (the nn4-lineage monkeypatch
installed over CompiledProgram.register_network) still had the pre-Task-1
fixed signature, with no path for arity/arg_sorts/artifact_hash to reach
the underlying native call — every public register_network() call
carrying them would TypeError before reaching the Rust validation this
surface exists to test. Threaded the three kwargs through positionally,
ahead of the wrapper's own keyword-only lineage params.

test_neural_credit.py gets one CPU test,
test_masked_row_probability_is_byte_invariant_in_the_or, the byte-
invariance probe promised to the external reviewer in #157: two p_event
vectors differing only at a masked witness row (0.01 vs 0.99) produce
bitwise-identical noisy_or_from_index results, with a sanity check that
changing an active row does change the OR.

* fix(neural): converge registration seam with the consumer's field contract — int sort ids with the bool trap, keyword-only bridge kwargs

The consumer's published contract (#155) requires arg_sorts to be catalog
sort ids (tuple[int, ...]), not sort names, with bool explicitly excluded
even though isinstance(True, int) holds in Python; and requires arity,
arg_sorts, and artifact_hash to be keyword-only, matching
register_network(..., cache_size, *, arity, arg_sorts, artifact_hash).

- pyxlog::neural::register_network now accepts arg_sorts as a Python
  sequence (Vec<PyObject>), validating each element is an int and not a
  bool before extracting to i64 -- the bool check runs first since pyo3
  extracts bool into i64 silently otherwise.
- NetworkConfig::arg_sorts and NetworkHandle::arg_sorts change from
  Option<Vec<String>> to Option<Vec<i64>>; network_metadata returns ints.
- The three kwargs move behind `*` in the pyo3 signature; the __init__.py
  nn4-lineage wrapper mirrors this and forwards them as keywords instead
  of positionally (its old positional forward would have broken once the
  native signature went keyword-only).
- Rust unit tests in registry.rs/handle.rs and the Python tests in
  TestRegistrationMetadata are updated to int sort ids, with new tests for
  the bool trap, a non-int element, and keyword-only enforcement.

* docs(pyxlog): type stubs cover the registration-metadata surface

register_network gains the keyword-only bridge kwargs and network_metadata
is declared -- the stub was stale since before the seam delta.
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