feat(ilp): witness-level mask/abstain channel per the #155 field contract - #157
Conversation
…exact-zero credit)
…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.
|
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)
Non-blocking notes (none blocks merge)
Test discipline is excellent — 11 tests cover every property above, including the two seam edges (out-of-range-under-mask typed refusal; multi-relation |
The list was collected and never read; asserting the mask actually reached a neural spec keeps the exact-zero-gradient assertions from passing vacuously.
|
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 ( 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. |
…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.
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 byenumerate_specs,train_engine_mode,kfold_select,frozen_select. DefaultNone— 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.0on 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.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;HoldoutSelectiongains acoveragedict. Relational candidates carry no mask state (masked_any=None) — "not neural's domain" stays distinct from "neural abstained".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).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 onrule, 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)
kfold_selectrecompiles 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
None/current behavior; the consumer pins integration on the merged surface, so backward compatibility is contract, not courtesy.mask_reasonis deliberately NOT carried here: the boolean channel + coverage accounting are this surface's scope; the reason taxonomy stays consumer-side until the kernel carries a state channel (stated in Engine-mode follow-ups: witness-level mask schema, registration-seam delta (arity/sorts/artifact hashes), frozen entry point #155).