Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .claude/board/entries/2026-10-28-d-epi-cot-c3-warrant.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# D-EPI-COT-C3 (first slice): tactics run only on grounded, Epi5-eligible markers

**Status:** TEST-PINNED. Code:
- `crates/lance-graph-planner/src/nars/warrant.rs`
- `epistemic_state5::RecipeRule`
- `certification::CertificationModel::measured_only`

Plan: `docs/research/epistemic-cot/c3-plan.md` §8.

## Decision

- **SCOPE:** a planner tactic (RCR 4, TR 6, CAS 8) runs only on `Marker`s. A marker is three things together:
- the ledger's pooled truth and roots;
- bits 59..63 projected through `Epi5Declarations`;
- a thought slot and generation.

Each premise must satisfy the recipe's `RecipeRule`. The rule's requires/forbids are policy pins: Associated for 4/6/7/8, and no intermediate-known for RCR.
- **Judgments** carry the tactic's truth, the proof's roots, the thought, and `Unknown × Open`. A term-logic step earns no certification. The only promotion is `pearl::reason` + `revise` over executed trial arms (pinned in the same test).
- **`measured_only`:** an unmeasured outcome is excluded, not read as negative. That includes trial arms, which `causes` reads without the universe.

## Evidence

- 9 new tests. 9 disables, each red.
- One disable (trial arms unrestricted) was green on the first fixture: every control unit was measured. An unmeasured-control fixture was added before recording.
- Planner lib 485 pass; contract lib 1561 pass.
- Clippy `--lib --tests` reports the same 8 sites as main `694b243bd`.

## Open

- Effect size, sample size, uncertainty and multiple comparisons are still unreported by `CertificationModel`.
- Missing-at-random is assumed.
- ASC and CR are not run through `fire`.
- The lab affordance law still has its own rule type.
- C3b and C3c wait on the bits 50..52 order decision and the TFLite location.

## Addendum, same day: ledger → `pearl::hydrate`

- `nars::mediator::hydrate_from_ledger` connects the ledger's grounded deductions of `A → Y` through `B` to the unchanged `pearl::hydrate`. It returns derivations, roots, the `Hydration` result, the edge and the mediated-path sensitivity.
- The ledger never creates a chain, and a path the chain does not witness leaves the edge unchanged. A chain-only path has no ledger receipt and is refused.
- `Law::Deduction` admits `Impl` in an explicit step: never closure, and `Rel` never composes.
- 5 tests; 5 disables red. The first grounding disable did not bind (no proofs exist without evidence); it was replaced before recording.
- Maturation order for new declared readings is `7 = Staunen … 0 = Wisdom`. The TFLite kernels are in ndarray.
3 changes: 2 additions & 1 deletion .claude/board/entries/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,15 @@ index row, (3) no duplicate entry id. Checks 1 and 2 are deliberately
opposite directions; the stranding this convention prevents shows up in
exactly one of them, never both.

498 entries, 2026-08-06 .. 2026-10-28.
499 entries, 2026-08-06 .. 2026-10-28.

| date | entry id | finding | file |
|---|---|---|---|
| 2026-10-28 | `thought-layers-nan-skip` | | [2026-10-28-thought-layers-nan-skip.md](2026-10-28-thought-layers-nan-skip.md) |
| 2026-10-28 | `lane-layout-from-classview` | | [2026-10-28-lane-layout-from-classview.md](2026-10-28-lane-layout-from-classview.md) |
| 2026-10-28 | `D-SAG-6-1` | | [2026-10-28-d-sag-6-1-audit-reference-ledger.md](2026-10-28-d-sag-6-1-audit-reference-ledger.md) |
| 2026-10-28 | `D-EPI-COT-C3B` | | [2026-10-28-d-epi-cot-c3b-scent.md](2026-10-28-d-epi-cot-c3b-scent.md) |
| 2026-10-28 | `D-EPI-COT-C3` | | [2026-10-28-d-epi-cot-c3-warrant.md](2026-10-28-d-epi-cot-c3-warrant.md) |
| 2026-10-28 | `D-EPI-COT` | | [2026-10-28-d-epi-cot-c0-c2.md](2026-10-28-d-epi-cot-c0-c2.md) |
| 2026-10-28 | `D-DT64-6` | | [2026-10-28-d-dt64-6-routing-bindstore-behavior.md](2026-10-28-d-dt64-6-routing-bindstore-behavior.md) |
| 2026-10-28 | `D-DT64-6-1a` | | [2026-10-28-d-dt64-6-ogit-predicates-impl.md](2026-10-28-d-dt64-6-ogit-predicates-impl.md) |
Expand Down
81 changes: 81 additions & 0 deletions crates/lance-graph-contract/src/certification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,35 @@ impl<M: PopulationMask> CertificationModel<M> {
)
}

/// The model restricted to the units whose outcome was measured.
///
/// `outcome` is a positive mask: a unit outside it reads as a known
/// negative. A unit whose outcome is UNKNOWN must not read that way, so
/// before certifying, every mask (population, strata, ordering,
/// robustness, and the trial arms, which `causes` reads without the
/// universe) is intersected with `measured`. An arm left with no measured
/// unit refuses as [`NotGrounded::EmptyArm`] rather than deciding.
#[must_use]
pub fn measured_only(&self, measured: &M) -> Self {
let keep = |m: &M| m.intersection(measured);
CertificationModel {
universe: keep(&self.universe),
exposed: keep(&self.exposed),
outcome: keep(&self.outcome),
strata: [
keep(&self.strata[0]),
keep(&self.strata[1]),
keep(&self.strata[2]),
keep(&self.strata[3]),
],
ordered: keep(&self.ordered),
clean: keep(&self.clean),
trial: keep(&self.trial),
assigned: keep(&self.assigned),
..self.clone()
}
}

/// The randomized arms read as a population of their own.
pub fn trial_view(&self) -> CertificationModel<M> {
CertificationModel {
Expand Down Expand Up @@ -458,6 +487,58 @@ impl<M: PopulationMask> ModelBuilder<M> {
mod tests {
use super::*;

/// Unmeasured outcomes are not negatives. Treated arm: 4 units with the
/// outcome, 4 whose outcome was never measured. Control arm: 3 of 8.
/// Reading the unmeasured units as negatives gives 4/8 against 3/8, so
/// `causes` holds; restricted to measured units it is 4/4 against 3/8.
/// The second fixture moves the gap the other way: 2 of 8 treated
/// measured positive, 6 unmeasured; as negatives 2/8 < 3/8 refutes, as
/// unknown 2/2 > 3/8 holds.
#[test]
fn unmeasured_outcomes_are_not_read_as_negatives() {
let build = |treated_hits: u32, treated_unknown: u32| {
let mut b = ModelBuilder::new();
b.sources(SupportBasis::InterventionBacked, &[1, 2]);
let (t_known, _) = b.arm(true, treated_hits, treated_hits);
let (t_unknown, _) = b.arm(true, treated_unknown, 0);
let (c, _) = b.arm(false, 8, 3);
let measured = t_known.union(&c);
assert!(!t_unknown.is_empty() && !t_unknown.intersects(&measured));
(b.build(), measured)
};

let (m, measured) = build(2, 6);
assert_eq!(m.causes(), Ok(false), "unknowns read as negatives refute");
assert_eq!(m.measured_only(&measured).causes(), Ok(true));

let (m, measured) = build(4, 4);
assert_eq!(m.causes(), Ok(true));
assert_eq!(m.measured_only(&measured).causes(), Ok(true));

// Unknowns in the control arm: treated 1 of 4 measured, control 3 of
// 8 measured plus 8 unmeasured. Read as negatives the control rate
// is 3/16 and treated (1/4) looks higher; restricted it is 3/8 and
// treated is lower.
let mut b = ModelBuilder::new();
b.sources(SupportBasis::InterventionBacked, &[1, 2]);
let (t, _) = b.arm(true, 4, 1);
let (c_known, _) = b.arm(false, 8, 3);
let (c_unknown, _) = b.arm(false, 8, 0);
let m = b.build();
let measured = t.union(&c_known);
assert!(!c_unknown.intersects(&measured));
assert_eq!(m.causes(), Ok(true), "unknown controls read as negatives");
assert_eq!(m.measured_only(&measured).causes(), Ok(false));

// An arm with no measured unit refuses instead of deciding.
let (m, _) = build(2, 6);
let control_only = m.trial.difference(&m.assigned);
assert_eq!(
m.measured_only(&control_only).causes(),
Err(NotGrounded::EmptyArm)
);
}

/// Every fold of a model, so two models can be compared fold by fold.
fn folds<M: PopulationMask>(m: &CertificationModel<M>) -> [Sat; 6] {
[
Expand Down
86 changes: 86 additions & 0 deletions crates/lance-graph-contract/src/epistemic_state5.rs
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,59 @@ pub const fn facts_population(required: Facts) -> Population {
population
}

/// A recipe's epistemic obligations: the facts a state must assert
/// (`requires`) and must not assert (`forbids`) for the recipe to be eligible
/// on an edge in that state.
///
/// This is the affordance law's rule, moved from the D-GSO-AFF-0 lab
/// (`cognitive-shader-driver/examples/shared/affordance_law.rs`) so a
/// production consumer can read it. Eligibility is a read-only derivation from
/// declared facts: it does not authorize execution, does not say a
/// conclusion is true, and never writes an edge.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct RecipeRule {
/// Facts every eligible state asserts.
pub requires: Facts,
/// Facts no eligible state asserts.
pub forbids: Facts,
}

impl RecipeRule {
/// A rule with these obligations.
#[must_use]
pub const fn new(requires: Facts, forbids: Facts) -> Self {
Self { requires, forbids }
}

/// Whether a state asserting `facts` satisfies the rule.
#[must_use]
pub const fn holds(self, facts: Facts) -> bool {
facts & self.requires == self.requires && facts & self.forbids == 0
}

/// Whether `state` satisfies the rule.
#[must_use]
pub const fn admits(self, state: EpistemicState5) -> bool {
self.holds(state.facts())
}

/// The V1 codes on which the rule holds. Reserved codes never appear.
#[must_use]
pub const fn population(self) -> Population {
let mut population = 0;
let mut raw = 0;
while raw < CODES {
if let Some(facts) = COMPILED_FACTS_V1[raw] {
if self.holds(facts) {
population |= 1 << raw;
}
}
raw += 1;
}
population
}
}

/// A reading generation. V1 is the 2 × 3 product above.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum Epi5Gen {
Expand Down Expand Up @@ -540,6 +593,39 @@ mod tests {
EpistemicState5::new(Epi5Gen::V1, t, c).raw()
}

/// A rule's population is exactly the meaningful codes it admits, and
/// `forbids` removes codes `requires` alone would keep.
#[test]
fn a_recipe_rule_population_matches_its_per_state_check() {
let rule = RecipeRule::new(ASSOCIATED, INTERMEDIATE_KNOWN);
let mut expected = 0;
for raw in 0u8..32 {
if let Ok(s) = EpistemicState5::decode(Epi5Gen::V1, raw) {
if rule.admits(s) {
expected |= 1 << raw;
}
}
}
assert_eq!(rule.population(), expected);
assert_eq!(
RecipeRule::new(ASSOCIATED, 0).population() & !rule.population(),
facts_population(ASSOCIATED | INTERMEDIATE_KNOWN),
"forbids removes exactly the states that assert the forbidden fact"
);
assert!(!rule.admits(EpistemicState5::new(
Epi5Gen::V1,
T::IndirectKnown,
C::Causes
)));
assert!(rule.admits(EpistemicState5::new(
Epi5Gen::V1,
T::IndirectUnknown,
C::Causes
)));
assert!(!rule.admits(EpistemicState5::new(Epi5Gen::V1, T::Direct, C::Open)));
assert_eq!(rule.population() >> 24, 0, "reserved codes never admitted");
}

/// The layout is the product: `raw5 = t | c << 2`, 24 meaningful, 8
/// reserved, and decode inverts it on every code.
#[test]
Expand Down
Loading
Loading