From 1ced9e2d3270c8aec18f40cb9864e655804338d6 Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Wed, 29 Jul 2026 23:44:52 +0200 Subject: [PATCH 1/5] WIP story 5.4b: decide() + the float-free gate, before the mutation pass Committed BEFORE running the prove-to-red mutations, deliberately: the first attempt used `git checkout` to restore a mutation while the baseline was uncommitted, which reverted to HEAD and destroyed the implementation. A mutation pass needs a committed baseline to restore TO. 271 -> 280 tests (135 bin + 100 core + 45 xtask). xtask ci: six gates green. Co-Authored-By: Claude Opus 5 (1M context) --- .../sprint-status.yaml | 2 +- crates/opencmdb-core/src/identity/cascade.rs | 430 ++++++++++++++++++ crates/opencmdb-core/src/lib.rs | 2 +- xtask/src/main.rs | 254 +++++++++++ 4 files changed, 686 insertions(+), 2 deletions(-) diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index eead290..bd73797 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -703,7 +703,7 @@ development_status: # not five; and the story cited `cascade.rs:42` for the f64 token when it is `:52` — a number # inherited from the register without re-measuring, which is the story's own inherited lesson #1 # committed by the story that quotes it. Caught before dev this time. - 5-4b-verdict-algebra-and-float-gate: ready-for-dev + 5-4b-verdict-algebra-and-float-gate: in-progress 5-5-l1-join-pure: backlog 5-6-blocker-and-recall-assertion: backlog 5-7-trap-runner-stops-scoring-nothing: backlog diff --git a/crates/opencmdb-core/src/identity/cascade.rs b/crates/opencmdb-core/src/identity/cascade.rs index 1597e19..af0f808 100644 --- a/crates/opencmdb-core/src/identity/cascade.rs +++ b/crates/opencmdb-core/src/identity/cascade.rs @@ -326,6 +326,146 @@ impl Decision { } } +/// Combine a verdict set into a [`Decision`] — D13's algebra, as a TOTAL pure function. +/// +/// D13: **all rules are evaluated** (never first-match-wins), each yields an enumerated verdict, and +/// *"verdicts combine by an **algebra, not a sum**"* [architecture.md:960-961]. This is that algebra. +/// It reads nothing but its arguments: no clock, no I/O, no SQL — *"the engine is a pure function"* +/// [architecture.md:3302], and *"the engine never touches the clock"* [architecture.md:3364]. +/// +/// # It is TOTAL, and the table it implements is not +/// +/// Every `Vec` gets an answer — the empty one, and one naming the same rule twice. +/// There is no `Result`, no `panic!` and no `unwrap`: there is no error to carry. +/// +/// D13's table has six rows. Enumerated over the PRESENCE of each verdict it leaves **exactly one +/// input class unanswered**, and this is the enumeration, so a reader can re-derive the gap rather +/// than trust it (`Disqualifying` absent throughout — it short-circuits): +/// +/// | `Decisive` | `Supports` | `Opposes` | D13 row | conclusion | +/// |---|---|---|---|---| +/// | ✔ | ✔ | ✔ | `:971` | `Ambiguous` | +/// | ✔ | ✔ | ✗ | `:970` | `Match` | +/// | ✔ | ✗ | ✔ | `:971` | `Ambiguous` | +/// | ✔ | ✗ | ✗ | `:970` | `Match` | +/// | ✗ | ✔ | ✔ | `:973` | `Ambiguous` | +/// | ✗ | ✔ | ✗ | `:972` | `Ambiguous` | +/// | ✗ | ✗ | ✔ | **NONE** | **arbitrated below** | +/// | ✗ | ✗ | ✗ | `:974` | `NoMatch` (absence of proof) | +/// +/// **Guy's arbitration, 2026-07-29: `>=1 Opposes` alone concludes +/// [`IdentityAbstentionCause::AbsenceOfProof`]** — nothing argues FOR the merge, so there is no merge +/// to refuse, and D13 deliberately reserves the refusal-that-names-a-rule for `Disqualifying`. The +/// correction to D13 itself is a MILESTONE edit of `architecture.md`, never a story task; it is +/// registered with a GitHub issue. +/// +/// # Which rule a decision names +/// +/// A decision names ONE rule while several may be `Disqualifying` or `Decisive` at once. The one +/// named is the **lexicographically smallest [`RuleId`] among the verdicts that qualified for that +/// arm** — the `Disqualifying` ones for a refusal, the `Decisive` ones for a match. +/// +/// ⚠️ **That is a TIEBREAK WITH NO SEMANTIC CONTENT.** `l1-distinct-mac` is not "more disqualifying" +/// than `l1-exact-mac`. It is chosen because it is the only order-independent rule available that +/// invents nothing: no rule priority exists, because **no rule exists**. D13 refuses the alternative +/// by name — *"which rule fires first? The one written first. **That is not a decision, it is an +/// accident of file order**"* [architecture.md:936-937]. A designed priority replaces it when rules +/// have one: story 5.5 for L1, Epic 6 for `l2-*`. Registered. +/// +/// # What it does not do +/// +/// It does not refuse a vector naming the same rule twice, and it does not validate +/// `ruleset_version` — including `RulesetVersion(0)`, which D14's "mandatory" does not forbid since +/// that is about PRESENCE. Both need a producer to state and to red, and no rule exists. +/// +/// The conclusion and the vector are built together here, so a conclusion naming a rule absent from +/// its own vector is unreachable **through this function** — the rule is selected FROM the vector +/// that is then returned, and `decide(vec![], _)` abstains rather than matching. A `Decision` built +/// by struct literal elsewhere is still unconstrained (the fields are `pub`); that residue is +/// registered with story 5.9. +pub fn decide(verdict_vector: Vec, ruleset_version: RulesetVersion) -> Decision { + let has = |w: Verdict| verdict_vector.iter().any(|rv| rv.verdict == w); + + // ⚠️ A `match` on the presence tuple, NOT an `if`/`else if` chain, and that is load-bearing: + // it is what makes deleting an arm an `error[E0004]` instead of a silent behaviour change. An + // `if`-chain with a trailing `else` was measured to swallow the loss of the arbitration arm + // while every one of the sixteen classes kept its answer. + let conclusion = match ( + has(Verdict::Disqualifying), + has(Verdict::Decisive), + has(Verdict::Supports), + has(Verdict::Opposes), + ) { + // architecture.md:969 — "any Disqualifying → NoMatch, absolute priority, short-circuits + // everything". Checked first, so the priority is structural and not an accident of order. + (true, _, _, _) => match smallest_rule_with(&verdict_vector, Verdict::Disqualifying) { + Some(rule) => Conclusion::NoMatch { rule }, + // Unreachable: `has(Disqualifying)` IS the presence of a RuleVerdict carrying it, so the + // candidate set is non-empty. Written as an arm rather than an `expect()` so that no + // path in this function can panic. + None => Conclusion::Abstained { + cause: IdentityAbstentionCause::AbsenceOfProof, + }, + }, + + // architecture.md:970 — "a Decisive, no Opposes → Match". + (false, true, _, false) => match smallest_rule_with(&verdict_vector, Verdict::Decisive) { + Some(rule) => Conclusion::Match { rule }, + // Unreachable, for the same reason as above. + None => Conclusion::Abstained { + cause: IdentityAbstentionCause::AbsenceOfProof, + }, + }, + + // architecture.md:971 — "a Decisive, >=1 Opposes → Ambiguous", the cloned-MAC case. + (false, true, _, true) => Conclusion::Abstained { + cause: IdentityAbstentionCause::Ambiguous, + }, + + // architecture.md:972 — "no Decisive, >=1 Supports, no Opposes → Ambiguous" (weak evidence). + (false, false, true, false) => Conclusion::Abstained { + cause: IdentityAbstentionCause::Ambiguous, + }, + + // architecture.md:973 — "Supports AND Opposes → Ambiguous" (conflict). + (false, false, true, true) => Conclusion::Abstained { + cause: IdentityAbstentionCause::Ambiguous, + }, + + // ⚠️ NOT a D13 row — this is the class the table does not cover, and there is no + // architecture line to cite. Guy's arbitration, 2026-07-29: `>=1 Opposes` with nothing + // arguing for the merge abstains for absence of proof. See this function's doc. + (false, false, false, true) => Conclusion::Abstained { + cause: IdentityAbstentionCause::AbsenceOfProof, + }, + + // architecture.md:974 — "only Neutral / nothing → NoMatch (absence of proof)". `Neutral` is + // not in the tuple because no row tests it: it is what is left when nothing else spoke. + (false, false, false, false) => Conclusion::Abstained { + cause: IdentityAbstentionCause::AbsenceOfProof, + }, + }; + + Decision { + conclusion, + verdict_vector, + ruleset_version, + } +} + +/// The lexicographically smallest [`RuleId`] among the verdicts carrying `wanted`, if any. +/// +/// The tiebreak of [`decide`], factored out so the two rule-naming arms cannot drift apart. It +/// returns an `Option` because the type system does not know that the caller has already established +/// presence; the callers discharge it with an arm rather than with `expect()`, so no path panics. +fn smallest_rule_with(verdicts: &[RuleVerdict], wanted: Verdict) -> Option { + verdicts + .iter() + .filter(|rv| rv.verdict == wanted) + .map(|rv| rv.rule.clone()) + .min() +} + /// Why the identity cascade did not conclude — the engine's own abstention vocabulary. /// /// # Two variants, and the two D13 rows that produce none @@ -687,4 +827,294 @@ mod tests { "the ruleset version is carried alongside, not derived" ); } + + // ── story 5.4b: the verdict algebra ────────────────────────────────────────────────────── + + /// D13's table, read directly and restated here as a SECOND, INDEPENDENT ORACLE. + /// + /// ⚠️ **Deliberate redundancy — do not collapse this into [`decide`] with a DRY pass.** It is the + /// same protected shape as `fixtures.rs`'s `expected()`, which restates the corpus bytes rather + /// than reading them through the code under test. A test that called `decide` to compute its own + /// expectation would prove nothing at all. + /// + /// It is written from the four PRESENCE booleans, in D13's own row order, and returns the full + /// [`Conclusion`] — **rule included**, because the rule is the half of a conclusion an arm can + /// silently get wrong. + fn expected_conclusion(v: &[RuleVerdict]) -> Conclusion { + let has = |w: Verdict| v.iter().any(|rv| rv.verdict == w); + // The lexicographically smallest rule among the verdicts carrying `w` — restated, not shared + // with the implementation. + let smallest = |w: Verdict| { + v.iter() + .filter(|rv| rv.verdict == w) + .map(|rv| rv.rule.clone()) + .min() + }; + + if has(Verdict::Disqualifying) { + // architecture.md:969 — "any Disqualifying → NoMatch, absolute priority". + match smallest(Verdict::Disqualifying) { + Some(rule) => Conclusion::NoMatch { rule }, + None => unreachable!("has(Disqualifying) implies a Disqualifying verdict exists"), + } + } else if has(Verdict::Decisive) && !has(Verdict::Opposes) { + // architecture.md:970 — "a Decisive, no Opposes → Match". + match smallest(Verdict::Decisive) { + Some(rule) => Conclusion::Match { rule }, + None => unreachable!("has(Decisive) implies a Decisive verdict exists"), + } + } else if has(Verdict::Decisive) { + // architecture.md:971 — "a Decisive, >=1 Opposes → Ambiguous" (the cloned-MAC case). + Conclusion::Abstained { + cause: IdentityAbstentionCause::Ambiguous, + } + } else if has(Verdict::Supports) { + // architecture.md:972 (weak evidence) and :973 (Supports AND Opposes, conflict) — with + // no Decisive, both land on Ambiguous whatever Opposes does. + Conclusion::Abstained { + cause: IdentityAbstentionCause::Ambiguous, + } + } else { + // architecture.md:974 — "only Neutral / nothing → NoMatch (absence of proof)", AND the + // class D13's table does not cover: >=1 Opposes with nothing arguing for the merge. + // Guy's arbitration, 2026-07-29. + Conclusion::Abstained { + cause: IdentityAbstentionCause::AbsenceOfProof, + } + } + } + + /// A subset of the five verdicts, as one `RuleVerdict` each with a distinct rule. + /// + /// Rules are named so that lexicographic order does NOT follow `Verdict::all()`'s order — + /// otherwise "smallest rule" and "first in the vector" would be indistinguishable here. + fn subset(mask: u32) -> Vec { + const NAMES: [&str; 5] = ["r-e", "r-c", "r-a", "r-d", "r-b"]; + Verdict::all() + .into_iter() + .enumerate() + .filter(|(i, _)| mask & (1 << i) != 0) + .map(|(i, verdict)| RuleVerdict { + rule: rule(NAMES[i]), + verdict, + evidence: vec![obs(100 + i as u128)], + }) + .collect() + } + + /// `decide` is TOTAL over D13's table: all 32 subsets of the five verdicts get an answer, and it + /// is the one D13's own text gives — including the empty vector, which is the `mask == 0` case. + /// + /// This is the "every input class, not a sample" the epic asks for: `2^5` presence combinations, + /// each checked against [`expected_conclusion`], an oracle written from the architecture rather + /// than from the code under test. + /// + /// ⚠️ **Every mismatch is collected and reported together, not asserted per iteration.** A bare + /// `assert_eq!` inside the loop aborts on the first one, which hides HOW MANY classes a change + /// moved — and that count is this test's coverage. Measured: it is the difference between "M1 + /// reds" and "M1 reds on exactly 2 of 32". + /// + /// ⚠️ **What it does NOT cover: the tiebreak.** Each subset carries at most one verdict of any + /// given kind, so no arm ever has two candidates and `min()` over a singleton is order-free. + /// `the_named_rule_does_not_depend_on_arrival_order` is the only coverage that property has. + #[test] + fn decide_is_total_over_every_one_of_d13s_input_classes() { + let mut mismatches = Vec::new(); + + for mask in 0..32u32 { + let vector = subset(mask); + let expected = expected_conclusion(&vector); + let decision = decide(vector.clone(), RulesetVersion(3)); + + if decision.conclusion != expected { + mismatches.push(format!( + "subset {mask:05b} {:?}: expected {expected:?}, got {:?}", + vector.iter().map(|rv| rv.verdict).collect::>(), + decision.conclusion + )); + } + assert_eq!( + decision.verdict_vector, vector, + "subset {mask:05b}: the verdict vector is carried through, not rebuilt" + ); + assert_eq!( + decision.ruleset_version, + RulesetVersion(3), + "subset {mask:05b}: the version is passed through verbatim" + ); + } + + assert!( + mismatches.is_empty(), + "{} of 32 input classes disagree with D13's table:\n {}", + mismatches.len(), + mismatches.join("\n ") + ); + } + + /// The class D13's six rows leave uncovered — `>=1 Opposes`, no `Decisive`, no `Supports`, no + /// `Disqualifying` — abstains for absence of proof. + /// + /// It is inside the 32-subset walk already; it gets its own name because it is the ONE answer no + /// architecture line backs. It is Guy's arbitration of 2026-07-29: nothing argues FOR the merge, + /// so there is no merge to refuse, and D13 reserves the refusal-that-names-a-rule for + /// `Disqualifying`. + #[test] + fn the_input_class_d13_does_not_cover_abstains_for_absence_of_proof() { + let vector = vec![RuleVerdict { + rule: rule("l2-different-switch"), + verdict: Verdict::Opposes, + evidence: vec![obs(1)], + }]; + + let decision = decide(vector, RulesetVersion(1)); + + assert_eq!( + decision.conclusion, + Conclusion::Abstained { + cause: IdentityAbstentionCause::AbsenceOfProof + }, + "an Opposes with nothing arguing for the merge abstains; it does not refuse, because a \ + refusal names a rule and D13 reserves that to Disqualifying" + ); + } + + /// The rule a decision names does not depend on the order the verdicts arrived in — D13's *"the + /// one written first… is not a decision, it is an accident of file order"*. + /// + /// ⚠️ Three verdicts of the SAME rule-naming kind, and their rule names supplied in an order that + /// is NOT their lexicographic order. Both are load-bearing: a mixed vector would conclude + /// `Abstained`, which names no rule, so every permutation would agree whatever the tiebreak did — + /// the test would pass with "first in the vector" and prove nothing. + #[test] + fn the_named_rule_does_not_depend_on_arrival_order() { + let mk = |name: &str| RuleVerdict { + rule: rule(name), + verdict: Verdict::Disqualifying, + evidence: vec![obs(7)], + }; + // Supplied c, a, b — lexicographic order differs from vector order. + let names = ["c", "a", "b"]; + let permutations = [ + [0, 1, 2], + [0, 2, 1], + [1, 0, 2], + [1, 2, 0], + [2, 0, 1], + [2, 1, 0], + ]; + + for order in permutations { + let vector: Vec = order.iter().map(|&i| mk(names[i])).collect(); + let decision = decide(vector, RulesetVersion(1)); + + assert_eq!( + decision.conclusion, + Conclusion::NoMatch { rule: rule("a") }, + "arrival order {order:?} must not change the rule named: the smallest RuleId wins, \ + not the first one in the vector" + ); + } + } + + /// A refusal names the rule that FORBADE the pair — never the smallest rule in the vector. + /// + /// The failing implementation this pins is a real one: taking `min()` over the WHOLE vector once + /// and reusing it in both rule-naming arms is deterministic, order-independent, and leaves the + /// named rule inside its own verdict vector — so it satisfies every other test here while + /// answering `NoMatch { rule: "a" }`, a refusal naming the rule that argued FOR the merge. + #[test] + fn a_disqualifying_names_the_disqualifying_rule_not_the_smallest_one() { + let vector = vec![ + RuleVerdict { + rule: rule("a"), + verdict: Verdict::Decisive, + evidence: vec![obs(1)], + }, + RuleVerdict { + rule: rule("z"), + verdict: Verdict::Disqualifying, + evidence: vec![obs(2)], + }, + ]; + + let decision = decide(vector, RulesetVersion(1)); + + assert_eq!( + decision.conclusion, + Conclusion::NoMatch { rule: rule("z") }, + "the refusal names the Disqualifying rule; 'a' argued FOR the pair and must not be the \ + rule a refusal cites" + ); + } + + /// Everything `decide` returns is coherent: a named rule is in the vector it travels with, and + /// an empty input can never come back as a `Match`. + /// + /// This is the invariant story 5.4 registered as unenforceable at the type level — `Decision`'s + /// fields are `pub` — and it holds here **by construction**: `decide` selects the rule FROM the + /// vector it then returns. A struct literal built elsewhere is still unconstrained; that residue + /// is registered with story 5.9. + #[test] + fn a_named_rule_is_always_present_in_the_vector_it_travels_with() { + for mask in 0..32u32 { + let decision = decide(subset(mask), RulesetVersion(1)); + if let Some(named) = decision.rule() { + assert!( + decision.verdict_vector.iter().any(|rv| &rv.rule == named), + "subset {mask:05b}: conclusion names {named:?}, which is absent from its own \ + verdict vector — 'merged, with no explanation'" + ); + } + } + + let empty = decide(Vec::new(), RulesetVersion(1)); + assert_eq!( + empty.conclusion, + Conclusion::Abstained { + cause: IdentityAbstentionCause::AbsenceOfProof + }, + "an empty verdict set abstains for absence of proof and can never be a Match: \ + 'merged, with no explanation' is unreachable through decide()" + ); + } + + /// A vector naming the same rule twice does not break totality — `decide` answers, and answers + /// the same way every time. + /// + /// `("a", Decisive)` + `("a", Opposes)` is the register's own example: ONE rule contradicting + /// itself, fabricating D13's *"a `Decisive`, >=1 `Opposes`"* conflict row on its own. ⚠️ This + /// pins TOTALITY, not refusal. Refusing a duplicated rule needs a PRODUCER that emits one verdict + /// per rule, and no rule exists — that half stays open with story 5.5. + #[test] + fn a_duplicated_rule_id_does_not_break_totality() { + let vector = vec![ + RuleVerdict { + rule: rule("a"), + verdict: Verdict::Decisive, + evidence: vec![obs(1)], + }, + RuleVerdict { + rule: rule("a"), + verdict: Verdict::Opposes, + evidence: vec![obs(2)], + }, + ]; + + let first = decide(vector.clone(), RulesetVersion(1)); + let second = decide(vector, RulesetVersion(1)); + + assert_eq!( + first.conclusion, + Conclusion::Abstained { + cause: IdentityAbstentionCause::Ambiguous + }, + "one rule contradicting itself is D13's Decisive-plus-Opposes row; decide answers rather \ + than refusing, because refusing needs a producer that does not exist" + ); + assert_eq!( + first.conclusion, second.conclusion, + "the same input answers the same way" + ); + } } diff --git a/crates/opencmdb-core/src/lib.rs b/crates/opencmdb-core/src/lib.rs index 4c47952..0ad1509 100644 --- a/crates/opencmdb-core/src/lib.rs +++ b/crates/opencmdb-core/src/lib.rs @@ -38,7 +38,7 @@ pub use clock::Clock; pub use connector::{Connector, ConnectorError, ObservationSink, PollSummary, VecSink}; pub use gap::{AbstentionCause, Gap, Reconciliation, reconcile}; pub use identity::cascade::{ - Conclusion, Decision, IdentityAbstentionCause, RuleVerdict, RulesetVersion, Verdict, + Conclusion, Decision, IdentityAbstentionCause, RuleVerdict, RulesetVersion, Verdict, decide, }; pub use observation::{ Capabilities, ConnectorId, Fact, FactKind, HostnameSource, L2DomainId, MacAddr, MacParseError, diff --git a/xtask/src/main.rs b/xtask/src/main.rs index b1783c7..b98f808 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -15,6 +15,13 @@ //! - **fixtures** (D56): a lockfile for data, checked in BOTH directions — a listed //! artefact whose bytes changed is red, and a file present under `fixtures/` that //! nobody listed is red. `fixtures/MANIFEST.toml` carries sha256 + optional generator. +//! - **file-size**: no source file over 2000 CODE lines (tests excluded — the count +//! stops at the first top-level `#[cfg(test)]`, D56b). A file past the ceiling is +//! split into modules, not grown. Names the offender and its count. +//! - **float-free** (D13): no `f32`, no `f64` and no float literal in CODE under +//! `crates/opencmdb-core/src/identity/` — *"if the output is a float, B has won in +//! disguise"*. Comments are stripped first, so the architecture may be QUOTED there; +//! the committed citation in `cascade.rs` is this gate's negative test case. //! - **views-hash** (informational): whether `architecture-views.md`'s `sourceSha256` //! still matches `architecture.md`. A mismatch means the views file is stale and //! should be regenerated at the next milestone — reported, never a hard failure. @@ -160,6 +167,10 @@ fn run_ci() -> Result { report("file-size", g4, &m4); ok &= g4; + let (g5, m5) = gate_float_free(&root)?; + report("float-free", g5, &m5); + ok &= g5; + let m3 = check_views_hash(&root)?; println!(" ℹ {:<14} {m3}", "views-hash"); @@ -284,6 +295,127 @@ fn crates_present_in_tree(tree: &str) -> HashSet { names } +// ── Gate 5: no float under identity/ (D13) ────────────────────────────────── + +/// The subtree the float gate guards. D13 refuses a float at the DECISION boundary, and this is +/// where the decision lives. +const IDENTITY_DIR: &str = "crates/opencmdb-core/src/identity"; + +/// The float token, or a float literal, in a line of CODE — comments stripped. +/// +/// D13: *"**REFUSED: `rule -> confidence: f64`.** A float compares, averages, thresholds — and we +/// are back to invented weights via the back door. **If the output is a float, B has won in +/// disguise.**"* [architecture.md:956-958]. +/// +/// # What it catches, and what it does not +/// +/// It strips from the first `//` to end of line, then looks for three shapes: +/// a word-bounded `f32`/`f64`; an `f32`/`f64` **suffix** on a numeric literal (`0.85f64`, `1f32`), +/// which has no word boundary before the `f`; and a bare **float literal** (`0.85`), which carries +/// no `f32`/`f64` token at all while still being an `f64` by inference. The last two were measured +/// as escapes of a word-boundary-only match, and a bare literal is the likeliest shape a weight +/// actually takes. +/// +/// Known limits, stated because a comment asserting a checkable property gets checked: +/// - a float inside a **block comment** `/* … */` is NOT stripped and reds — a false POSITIVE. None +/// exists under the guarded subtree today; if one appears, that is the sentence to revisit. +/// - a `//` inside a **string literal** truncates the line early, so a float after it is missed — a +/// false negative, and a harmless one: a float inside a string is not a float. +/// - `#[doc = "…"]` is not stripped and would red. None exists under the guarded subtree today. +fn line_has_float(line: &str) -> Option<&'static str> { + let code = match line.find("//") { + Some(i) => &line[..i], + None => line, + }; + for token in ["f32", "f64"] { + if contains_word(code, token) { + return Some("f32/f64 type"); + } + // A suffix binds to the digits before it, so `contains_word` cannot see it. + if code.contains(token) { + return Some("f32/f64 literal suffix"); + } + } + if has_float_literal(code) { + return Some("float literal"); + } + None +} + +/// A bare decimal literal: a digit, a dot, a digit. `1.0`, `0.85`. Not `x.0` (tuple field), not +/// `1..2` (range), not `architecture.md:967-974`. +fn has_float_literal(code: &str) -> bool { + let b = code.as_bytes(); + for i in 1..b.len().saturating_sub(1) { + if b[i] == b'.' && b[i - 1].is_ascii_digit() && b[i + 1].is_ascii_digit() { + return true; + } + } + false +} + +/// No float may reach a decision — D13's clause, held mechanically rather than by accident. +/// +/// Before this gate the rule was true by measurement only: the whole workspace contained zero +/// `f32`/`f64` in code. A gate is what keeps it true. +/// +/// It walks [`IDENTITY_DIR`] **recursively**, because the architecture's own source tree names a +/// future `identity/field_decision/` [architecture.md:3370-3372] and a flat read would go blind the +/// day it is created. +/// +/// It **fails CLOSED** if the directory is missing. The DDL gate reports "nothing to check" when its +/// directory is absent, but that directory does not exist yet; this one does, so its disappearance +/// is a finding — the fixture gate's reasoning, *"reporting 'nothing to check' on the deletion of the +/// thing being guarded is a guarantee the gate does not have"*. +fn gate_float_free(root: &Path) -> Result<(bool, String)> { + let dir = root.join(IDENTITY_DIR); + if !dir.exists() { + return Ok(( + false, + format!( + "{IDENTITY_DIR} is missing — the guarded subtree must exist for this gate to mean anything" + ), + )); + } + + let mut offenders = Vec::new(); + let mut checked = 0usize; + for entry in walkdir::WalkDir::new(&dir) + .into_iter() + .filter_map(Result::ok) + { + let p = entry.path(); + if p.extension().and_then(|e| e.to_str()) != Some("rs") { + continue; + } + let content = + std::fs::read_to_string(p).with_context(|| format!("reading {}", p.display()))?; + checked += 1; + for (i, line) in content.lines().enumerate() { + if let Some(what) = line_has_float(line) { + let shown = p.strip_prefix(root).unwrap_or(p); + offenders.push(format!("{}:{}: {what}", shown.display(), i + 1)); + } + } + } + + if offenders.is_empty() { + Ok(( + true, + format!("no float in code across {checked} file(s) under {IDENTITY_DIR}"), + )) + } else { + Ok(( + false, + format!( + "{} float(s) where a decision is made — D13 refuses it:\n {}", + offenders.len(), + offenders.join("\n ") + ), + )) + } +} + // ── Gate 1: DDL binary collation (D64 condition 1) ────────────────────────── fn gate_ddl_collation(root: &Path) -> Result<(bool, String)> { @@ -1481,4 +1613,126 @@ opencmdb-core v0.1.0 (/w/crates/opencmdb-core) MAX_CODE_LINES + 1 ); } + + // ── float-free gate (D13) ──────────────────────────────────────────────────────────────── + + /// The line classifier, shape by shape — including the three a word-boundary-only match was + /// MEASURED to miss, which is why they are pinned individually rather than by one example. + #[test] + fn float_line_classifier_catches_types_suffixes_and_bare_literals() { + // The obvious one. + assert!(line_has_float(" let _x: f64 = 0.0;").is_some()); + assert!(line_has_float("fn w(x: f32) -> f32 { x }").is_some()); + + // A suffix binds to the digits, so there is no word boundary before the `f`. + assert!( + line_has_float(" let confidence = 0.85f64;").is_some(), + "a suffixed literal escapes a word-boundary match" + ); + assert!(line_has_float(" let c = 1f32;").is_some()); + + // A bare literal carries no f32/f64 token at all, and is an f64 by inference. This is the + // likeliest shape an invented weight actually takes. + assert!( + line_has_float(" let confidence = 0.85;").is_some(), + "a bare decimal literal is a float even with no type named" + ); + + // Comments are stripped: the architecture may be quoted. + assert!( + line_has_float("/// *\"REFUSED: `rule -> confidence: f64`\"* [architecture.md:956]") + .is_none(), + "a citation of D13 is prose, not a float" + ); + assert!(line_has_float("//! the algebra refuses f64 outright").is_none()); + assert!(line_has_float(" let n = 1u32; // not an f64 either").is_none()); + + // Things that merely look like floats. + assert!( + line_has_float("/// [architecture.md:967-974]").is_none(), + "a line-number range is not a float" + ); + assert!( + line_has_float(" let a = t.0;").is_none(), + "a tuple field access is not a float" + ); + assert!( + line_has_float(" for i in 1..32 {").is_none(), + "a range is not a float" + ); + assert!(line_has_float(" let s: u32 = 42;").is_none()); + } + + /// The gate itself, against a temp tree — the walk, the recursion, and the missing directory. + /// + /// Testing only [`line_has_float`] would leave all three untested while the gate read as + /// covered, so this drives `gate_float_free` end to end. + #[test] + fn float_gate_walks_recursively_strips_comments_and_fails_closed() { + let root = scratch("float-gate"); + let guarded = root.join(IDENTITY_DIR); + let nested = guarded.join("field_decision"); + std::fs::create_dir_all(&nested).expect("nested dir"); + + // A quotation of D13, which must NOT red — the regression the stripping exists for. + std::fs::write( + guarded.join("cascade.rs"), + "/// *\"REFUSED: `rule -> confidence: f64`\"* [architecture.md:956-958]\npub fn ok() {}\n", + ) + .expect("write"); + let (green, msg) = gate_float_free(&root).expect("the gate runs"); + assert!(green, "a float quoted in a doc comment must not red: {msg}"); + + // A real float in a NESTED file: proves the walk recurses. + std::fs::write(nested.join("weights.rs"), "pub fn w() -> f64 { 0.5 }\n").expect("write"); + let (green, msg) = gate_float_free(&root).expect("the gate runs"); + assert!( + !green, + "a float in a nested subdirectory must red — a flat read would go blind" + ); + assert!( + msg.contains("field_decision/weights.rs"), + "the message names the offending file: {msg}" + ); + + // A bare literal, in the guarded directory itself. + std::fs::remove_file(nested.join("weights.rs")).expect("rm"); + std::fs::write(guarded.join("score.rs"), "pub fn w() { let _c = 0.85; }\n").expect("write"); + let (green, msg) = gate_float_free(&root).expect("the gate runs"); + assert!(!green, "a bare float literal must red: {msg}"); + + // FAILS CLOSED when the guarded subtree is gone — the fixture gate's reasoning, not the + // DDL gate's: this directory exists today, so its disappearance is a finding. + std::fs::remove_dir_all(&guarded).expect("rm -r"); + let (green, msg) = gate_float_free(&root).expect("the gate runs"); + assert!( + !green, + "a missing guarded subtree must RED, not report 'nothing to check': {msg}" + ); + assert!(msg.contains("missing"), "{msg}"); + + let _ = std::fs::remove_dir_all(&root); + } + + /// The gate is GREEN on the real tree — and the committed D13 citation is why that is a claim + /// worth pinning rather than a tautology. + /// + /// `crates/opencmdb-core/src/identity/cascade.rs` quotes *"REFUSED: `rule -> confidence: f64`"* + /// in a `///` block. It is the workspace's only `f64` token, and a naive line grep would red on + /// it. This test is what keeps the stripping from being removed as "an optimisation". + #[test] + fn float_gate_is_green_on_the_real_tree_despite_the_committed_d13_citation() { + let root = workspace_root(); + let (green, msg) = gate_float_free(&root).expect("the gate runs"); + assert!(green, "the real tree carries no float in code: {msg}"); + assert!(msg.contains("no float in code"), "{msg}"); + + let cascade = root.join(IDENTITY_DIR).join("cascade.rs"); + let source = std::fs::read_to_string(&cascade).expect("cascade.rs is readable"); + assert!( + source.contains("f64"), + "the citation this gate must tolerate has moved or gone — if it was removed on purpose, \ + this test's premise is what needs revisiting" + ); + } } From 232de59024051cb7c1c635382e8acae58b6a6fd8 Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Thu, 30 Jul 2026 00:12:46 +0200 Subject: [PATCH 2/5] Story 5.4b: the verdict algebra is total, and a gate refuses the float MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `identity/cascade.rs` gains `decide(Vec, RulesetVersion) -> Decision` — D13's table as a TOTAL pure function — plus its private `smallest_rule_with`. `xtask` gains a sixth gate, `float-free`. Six new core tests, three new xtask tests. 271 -> 280 (135 bin + 100 core + 45 xtask). NOTHING produces a Verdict, so nothing calls decide outside its own tests: no rule, no join, no blocker, no persistence, no corpus wiring. THE DESIGN. `decide` returns a Decision, not a bare Conclusion, so the returned verdict_vector IS the input and the named rule is selected FROM it. That makes "a conclusion naming a rule absent from its own vector" and "a Match with an empty vector" unreachable THROUGH THE FUNCTION rather than merely unenforced — the invariant story 5.4 registered as untestable. A struct literal built elsewhere is still free; that residue moved to story 5.9. THE ARMS are a `match` on the presence tuple, not an if-chain, and that is load-bearing rather than stylistic: deleting the arbitration arm gives `error[E0004]: non-exhaustive patterns: (false, false, false, true) not covered` — the compiler NAMES the missing class. Validation had measured that an if-chain swallows the same deletion with all 16 classes keeping their answer. THE ARBITRATION. D13's six rows leave exactly one input class uncovered — >=1 Opposes with no Decisive, no Supports, no Disqualifying. Guy's call of 2026-07-29: it abstains for absence of proof. The correction to D13's own table is a MILESTONE act, never a story task, and is now GitHub issue #54. FIVE mutations run. TWO predictions corrected BY MEASUREMENT: - M1 (the conflict arm's cause) -> exactly 2 of 32 subsets. That count is only observable because the 32-walk was made CUMULATIVE: a bare assert_eq! inside the loop aborts on the first mismatch, so the first run reported one subset where two had moved. The difference between "M1 reds" and "M1 reds on exactly 2 of 32" is the walk's coverage, measured. - M4 (remove the comment-stripping) -> 47 offenders on the real tree, not 1. The other 46 are STORY REFERENCES in prose: `5.4b`, `4.6a`, `4.7a` are literally digit-dot-digit, because the gate also matches BARE float literals — `let confidence = 0.85;` carries no f32/f64 token at all and is the likeliest shape an invented weight takes. The stripping and the literal rule are load-bearing together, and the gate's doc now says so with the number. - M2 -> error[E0004]. M3 (min -> next) -> ONLY the permutation test reds, the 32-walk stays green, confirming that the walk gives the tiebreak zero coverage. M5 (a real float) -> 2 reds naming file and line, not 1. ONE of the five reds is compiler-carried (M2) and four are not. M5 was observed through `cargo xtask ci`'s output rather than a test run, and saying so is the honest form. Story 5.4's record claimed two of four were assertion-carried when only one was; this is the corrected shape. TWELVE doc locations corrected, found by grepping the final tree rather than from the story's list of five. Three do not name 5.4b and that list would have missed them — chiefly AbsenceOfProof's doc, which read "nothing in the verdict set argues either way" while the arbitrated class it now serves carries an Opposes. The gap-hunt validation agent caught that one before dev. REGISTER: eight entries annotated across four sections (validation corrected "six in one"). The four NoMatch annotation lines in the 4.6a/4.7a sections said "nothing decides which side an input falls on" — decide falsifies that, and the Outcome-mapping half moves to 5.7. The empty-vector-literals entry is closed by its own prediction being REFUTED: the literals compiled untouched, and its count was wrong (four sites, not six). A new section opens nine items: six name a story, two name a condition, one names a milestone; one of the six also names Epic 6. Counted after the last edit — a first draft of that preamble summed to nine only by counting the epic as an item and forgetting the milestone. ⚠️ ONE PROCESS DEFECT, recorded because it cost the implementation once: the first mutation pass ran against an UNCOMMITTED baseline and restored with `git checkout`, which reverts to HEAD and wiped decide and its tests. Re-applied, then committed before re-running. A mutation pass needs a committed baseline to restore TO — the story's warning named the symptom, not the mechanism. Gate run whole: fmt clean; clippy --all-targets clean; clippy WITHOUT --all-targets clean (the CI form); 280 tests; xtask ci six gates green with views-hash STALE exit 0 (issue #50, NOT regenerated); fixtures/ untouched; cargo doc the same three pre-existing warnings. epics.md and architecture.md verified and NOT edited. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 +- .../5-4b-verdict-algebra-and-float-gate.md | 270 +++++++++++++----- .../implementation-artifacts/deferred-work.md | 125 ++++++++ .../sprint-status.yaml | 65 ++++- crates/opencmdb-core/src/identity/cascade.rs | 84 ++++-- crates/opencmdb-core/src/identity/mod.rs | 10 +- docs/project-context.md | 2 +- xtask/src/main.rs | 10 + 8 files changed, 465 insertions(+), 103 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 13bee34..cf690d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project status -`opencmdb` is a self-hosted, single-binary **Rust** network reconciliation engine (IPAM + light app CMDB + topology) for home-lab/SMB. Core thesis: continuously compare the **observed** state (auto-discovered) against the **declared** state (documented); the gap is the product. **Planning is COMPLETE** (product brief, PRD, UX spec, architecture — all in `_bmad-output/planning-artifacts/`, decision register D1–D66). **As of 2026-07-22 the code RUNS**: `v0.1.1` is tagged and published to Docker Hub as `gcorbaz/opencmdb`, and it scans a CIDR and shows a real observed-vs-declared gap on one page. **Epics 1–4 of 23 are done** (the walking skeleton shipped as v0.1). **Epic 4 closed on 2026-07-25**: the fixture corpus, the metrics harness and the trap corpus — all written BEFORE the identity engine — are committed and locked (25 artefacts, 24 traps across nine families, plus the wire-format spec). Its story 4.19 was SPLIT at closure: 4.19a shipped, **4.19b (the mutation generator) moved to Epic 11** — recorded in `epic-4-correct-course-2026-07-25.md` and GitHub issue #34, not silently. **Epic 5 — the identity engine — is IN PROGRESS since 2026-07-27** (**16 stories**: 5.4b was INSERTED 2026-07-29 at story 5.4's contexting, splitting the verdict algebra — a function that must be TOTAL over a table D13 leaves one input class short — and its anti-float `xtask` gate out of 5.4). Its **three inherited debt stories come first and are all `done`** — 5.1, 5.2 and 5.2b, PRs #41, #44 and #46, all merged 2026-07-28. The engine proper starts at **5.3, `done`** — PR #48, merged 2026-07-29 — which ships the identity engine's own abstention vocabulary (`IdentityAbstentionCause`, in the new `crates/opencmdb-core/src/identity/`) and **no engine**: no cascade, no rule, no `Decision`, no join. **5.4 follows and is `done`** — PR #52, squash-merged 2026-07-29 as `da87b62`: it adds the engine's RETURN type in the same file — `Verdict` (D13's five verdicts), `RuleVerdict { rule, verdict, evidence }`, `RulesetVersion`, `Conclusion` and `Decision` — and still **no algebra**: nothing combines a verdict set, no rule produces a verdict, nothing produces a decision. **Next is 5.4b, `ready-for-dev`** (contexted and validated 2026-07-29): `decide(Vec, RulesetVersion) -> Decision` as a TOTAL pure function over D13's six rows plus the one input class the table leaves uncovered (`≥1 Opposes` alone → `Abstained { AbsenceOfProof }`, Guy's arbitration), and the `xtask` gate that refuses a float under `identity/`. _(This sentence named only 5.1 until 2026-07-29 — stale for a day, found by story 5.3's code review, not by the two stories that caused the drift.)_ Live status is `_bmad-output/implementation-artifacts/sprint-status.yaml`; grounding is `docs/project-context.md`. +`opencmdb` is a self-hosted, single-binary **Rust** network reconciliation engine (IPAM + light app CMDB + topology) for home-lab/SMB. Core thesis: continuously compare the **observed** state (auto-discovered) against the **declared** state (documented); the gap is the product. **Planning is COMPLETE** (product brief, PRD, UX spec, architecture — all in `_bmad-output/planning-artifacts/`, decision register D1–D66). **As of 2026-07-22 the code RUNS**: `v0.1.1` is tagged and published to Docker Hub as `gcorbaz/opencmdb`, and it scans a CIDR and shows a real observed-vs-declared gap on one page. **Epics 1–4 of 23 are done** (the walking skeleton shipped as v0.1). **Epic 4 closed on 2026-07-25**: the fixture corpus, the metrics harness and the trap corpus — all written BEFORE the identity engine — are committed and locked (25 artefacts, 24 traps across nine families, plus the wire-format spec). Its story 4.19 was SPLIT at closure: 4.19a shipped, **4.19b (the mutation generator) moved to Epic 11** — recorded in `epic-4-correct-course-2026-07-25.md` and GitHub issue #34, not silently. **Epic 5 — the identity engine — is IN PROGRESS since 2026-07-27** (**16 stories**: 5.4b was INSERTED 2026-07-29 at story 5.4's contexting, splitting the verdict algebra — a function that must be TOTAL over a table D13 leaves one input class short — and its anti-float `xtask` gate out of 5.4). Its **three inherited debt stories come first and are all `done`** — 5.1, 5.2 and 5.2b, PRs #41, #44 and #46, all merged 2026-07-28. The engine proper starts at **5.3, `done`** — PR #48, merged 2026-07-29 — which ships the identity engine's own abstention vocabulary (`IdentityAbstentionCause`, in the new `crates/opencmdb-core/src/identity/`) and **no engine**: no cascade, no rule, no `Decision`, no join. **5.4 follows and is `done`** — PR #52, squash-merged 2026-07-29 as `da87b62`: it adds the engine's RETURN type in the same file — `Verdict` (D13's five verdicts), `RuleVerdict { rule, verdict, evidence }`, `RulesetVersion`, `Conclusion` and `Decision` — and still **no algebra**: nothing combines a verdict set, no rule produces a verdict, nothing produces a decision. **5.4b follows and is in `review`**: it ships the ALGEBRA — `decide(Vec, RulesetVersion) -> Decision`, a TOTAL pure function over D13's six rows plus the one input class the table leaves uncovered (`≥1 Opposes` alone → `Abstained { AbsenceOfProof }`, Guy's arbitration; the D13 correction itself is **GitHub issue #54**, a milestone act) — and a **sixth `cargo xtask ci` gate, `float-free`**, which reds on an `f32`, an `f64` or a bare float literal in code under `identity/`, comments stripped so the architecture may be quoted. **Still no rule and no producer**: nothing emits a `Verdict`, so nothing calls `decide` outside its own tests (5.5 owns the join). Next is 5.5. _(This sentence named only 5.1 until 2026-07-29 — stale for a day, found by story 5.3's code review, not by the two stories that caused the drift.)_ Live status is `_bmad-output/implementation-artifacts/sprint-status.yaml`; grounding is `docs/project-context.md`. ### Build / lint / test commands (the stack is chosen and building) diff --git a/_bmad-output/implementation-artifacts/5-4b-verdict-algebra-and-float-gate.md b/_bmad-output/implementation-artifacts/5-4b-verdict-algebra-and-float-gate.md index 41810d8..dc68a50 100644 --- a/_bmad-output/implementation-artifacts/5-4b-verdict-algebra-and-float-gate.md +++ b/_bmad-output/implementation-artifacts/5-4b-verdict-algebra-and-float-gate.md @@ -1,6 +1,6 @@ # Story 5.4b: The verdict algebra is a total function, and no float can reach it -Status: ready-for-dev +Status: review