Skip to main content

omena_cascade_proof/
lib.rs

1//! Product-owned cascade proof contracts.
2//!
3//! The default solver-free proof path is part of the shipped product surface:
4//! product diagnostics and transform safety checks rely on it even when no
5//! external solver is enabled. Solver-backed experiments live outside this crate.
6
7use omena_cascade::{
8    BoxLonghandInputV0, LayerFlattenInputV0, LonghandMergeInputV0, ScopeFlattenInputV0,
9    StaticSupportsAssumptionV0, StaticSupportsEvalVerdictV0, evaluate_static_supports_condition,
10    prove_box_shorthand_combination, prove_layer_flatten_candidate, prove_longhand_merge,
11    prove_scope_flatten_candidate,
12};
13use omena_evidence_graph::{
14    EvidenceDemandEdgeV0, EvidenceGraphBuildErrorV0, EvidenceGraphV0, EvidenceNodeKeyV0,
15    EvidenceNodeSeedV0, FamilyStampV0, GuaranteeKindV0, LedgerDischargeWitnessV0,
16    ObligationFamilyIdV0, ProseObligationProvenanceV0, build_evidence_graph_from_edges_v0,
17};
18use omena_refinement_trait::RefinementVerdictV0;
19use serde::Serialize;
20
21pub mod discharge_ledger;
22pub mod fuzz;
23pub mod proof_kernel;
24
25pub use discharge_ledger::{
26    DISCHARGE_LEDGER_PRODUCT_V1, DISCHARGE_LEDGER_SCHEMA_VERSION_V1, DischargeLedgerLookupStatusV0,
27    DischargeLedgerLookupV0, DischargeLedgerVerdictV0, discharge_ledger_cell_key_v0,
28    lookup_discharge_ledger_entry_v0,
29};
30pub use fuzz::{
31    SmtBisimulationFuzzCaseV0, SmtBisimulationFuzzReportV0, run_smt_bisimulation_fuzz_case_v0,
32    run_smt_bisimulation_fuzz_seed_corpus_v0, smt_bisimulation_fuzz_case_v0,
33};
34pub use proof_kernel::*;
35
36pub const SMT_SCHEMA_VERSION_V0: &str = "0";
37pub const SMT_LAYER_MARKER_V0: &str = "smt-cascade-verification";
38pub const SMT_FEATURE_GATE_V0: &str = "smt-stub";
39const REWRITE_PROOF_INPUT_EVIDENCE_QUERY_V0: &str = "omena-cascade-proof.transform-rewrite-input";
40const CASCADE_PROOF_RECORD_EVIDENCE_QUERY_V0: &str = "omena-cascade-proof.cascade-proof-record";
41const CASCADE_PROOF_EVIDENCE_EDGE_KIND_V0: &str = "cascade-proof-evidence";
42pub const TRANSFORM_REWRITE_PROOF_INPUT_OBLIGATION_FAMILY_V0: ObligationFamilyIdV0 =
43    ObligationFamilyIdV0::CascadeObligationDeclaration;
44
45fn prose_obligation_family_stamp(provenance: &[String]) -> FamilyStampV0 {
46    let Some(prose_provenance) = ProseObligationProvenanceV0::from_provenance_labels(provenance)
47    else {
48        unreachable!("prose evidence seeds include an obligation provenance label")
49    };
50    FamilyStampV0::prose_obligation_discharged(&prose_provenance)
51}
52
53const CASCADE_SMT_SPEC_MATERIAL_V0: &str = "\
54schema=0\n\
55theory=cascade-smt-theory-v0\n\
56encoding=canonical-smt-input-v0\n\
57default-backend=stub-propositional\n\
58opt-in-backend=smt-z3-qf-lia-layer-inversion\n\
59obligations=box-shorthand-combination,scope-flatten-candidate,layer-flatten-candidate,static-supports-condition\n\
60";
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
63#[serde(rename_all = "camelCase")]
64pub struct CanonicalSmtInputV0 {
65    pub schema_version: &'static str,
66    pub product: &'static str,
67    pub layer_marker: &'static str,
68    pub feature_gate: &'static str,
69    pub obligation_id: String,
70    pub l1_primitive: &'static str,
71    pub canonical_terms: Vec<String>,
72    pub smtlib2_script: String,
73}
74
75pub fn canonical_smt_input_v0(
76    obligation_id: impl Into<String>,
77    l1_primitive: &'static str,
78    canonical_terms: Vec<String>,
79) -> CanonicalSmtInputV0 {
80    let smtlib2_script = canonical_smtlib2_script_v0(&canonical_terms);
81    CanonicalSmtInputV0 {
82        schema_version: SMT_SCHEMA_VERSION_V0,
83        product: "omena-smt.canonical-input",
84        layer_marker: SMT_LAYER_MARKER_V0,
85        feature_gate: SMT_FEATURE_GATE_V0,
86        obligation_id: obligation_id.into(),
87        l1_primitive,
88        canonical_terms,
89        smtlib2_script,
90    }
91}
92
93pub fn canonical_smt_input_with_script_v0(
94    obligation_id: impl Into<String>,
95    l1_primitive: &'static str,
96    canonical_terms: Vec<String>,
97    smtlib2_script: String,
98) -> CanonicalSmtInputV0 {
99    CanonicalSmtInputV0 {
100        schema_version: SMT_SCHEMA_VERSION_V0,
101        product: "omena-smt.canonical-input",
102        layer_marker: SMT_LAYER_MARKER_V0,
103        feature_gate: SMT_FEATURE_GATE_V0,
104        obligation_id: obligation_id.into(),
105        l1_primitive,
106        canonical_terms,
107        smtlib2_script,
108    }
109}
110
111pub fn canonical_smtlib2_script_v0(canonical_terms: &[String]) -> String {
112    let mut script = String::from("(set-logic QF_UF)\n");
113    for term in canonical_terms {
114        if let Some((name, value)) = canonical_requirement_parts_v0(term) {
115            let symbol = smtlib2_named_assertion_symbol_v0(name);
116            let atom = if value { "true" } else { "false" };
117            script.push_str(&format!("(assert (! {atom} :named {symbol}))\n"));
118        } else {
119            let comment = smtlib2_comment_v0(term);
120            script.push_str(&format!("; {comment}\n"));
121        }
122    }
123    script
124}
125
126pub fn canonical_requirement_value_v0(term: &str) -> Option<bool> {
127    canonical_requirement_parts_v0(term).map(|(_, value)| value)
128}
129
130pub fn canonical_input_has_unknown_v0(input: &CanonicalSmtInputV0) -> bool {
131    input
132        .canonical_terms
133        .iter()
134        .any(|term| term.starts_with("unknown:"))
135}
136
137fn canonical_requirement_parts_v0(term: &str) -> Option<(&str, bool)> {
138    let (name, value) = term.strip_prefix("require:")?.rsplit_once('=')?;
139    match value {
140        "true" => Some((name, true)),
141        "false" => Some((name, false)),
142        _ => None,
143    }
144}
145
146fn smtlib2_named_assertion_symbol_v0(name: &str) -> String {
147    let mut symbol = String::from("req_");
148    for ch in name.chars() {
149        if ch.is_ascii_alphanumeric() {
150            symbol.push(ch);
151        } else {
152            symbol.push('_');
153        }
154    }
155    symbol
156}
157
158fn smtlib2_comment_v0(term: &str) -> String {
159    term.chars()
160        .map(|ch| match ch {
161            '\n' | '\r' => ' ',
162            _ => ch,
163        })
164        .collect()
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
168#[serde(rename_all = "camelCase")]
169pub enum SmtBackendKindV0 {
170    Stub,
171    Z3,
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
175#[serde(rename_all = "camelCase")]
176pub enum SmtBackendSatResultV0 {
177    Sat,
178    Unsat,
179    Unknown,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
183#[serde(rename_all = "camelCase")]
184pub struct SmtBackendCheckV0 {
185    pub schema_version: &'static str,
186    pub product: &'static str,
187    pub layer_marker: &'static str,
188    pub feature_gate: &'static str,
189    pub backend: SmtBackendKindV0,
190    pub obligation_id: String,
191    pub formula_count: usize,
192    pub sat_result: SmtBackendSatResultV0,
193    pub model_available: bool,
194}
195
196pub trait SmtBackendV0 {
197    fn backend_kind(&self) -> SmtBackendKindV0;
198
199    fn quantifier_elimination_tactic(&self) -> Option<&'static str> {
200        None
201    }
202
203    fn check_canonical_input_v0(&self, input: &CanonicalSmtInputV0) -> SmtBackendCheckV0 {
204        let sat_result = if canonical_input_has_unknown_v0(input) {
205            SmtBackendSatResultV0::Unknown
206        } else if input
207            .canonical_terms
208            .iter()
209            // Non-`require:` audit labels (`pass:...`, `decl:...`, and similar)
210            // are deliberately neutral here. This default backend re-reads only
211            // producer-authored Boolean requirement strings; it is not an
212            // independent semantic verification step.
213            .all(|term| canonical_requirement_value_v0(term).unwrap_or(true))
214        {
215            SmtBackendSatResultV0::Sat
216        } else {
217            SmtBackendSatResultV0::Unsat
218        };
219        SmtBackendCheckV0 {
220            schema_version: SMT_SCHEMA_VERSION_V0,
221            product: "omena-smt.backend-check",
222            layer_marker: SMT_LAYER_MARKER_V0,
223            feature_gate: SMT_FEATURE_GATE_V0,
224            backend: self.backend_kind(),
225            obligation_id: input.obligation_id.clone(),
226            formula_count: input.canonical_terms.len(),
227            sat_result,
228            model_available: matches!(sat_result, SmtBackendSatResultV0::Sat),
229        }
230    }
231}
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
234#[serde(rename_all = "camelCase")]
235pub struct StubSmtBackendV0 {
236    pub schema_version: &'static str,
237    pub product: &'static str,
238    pub layer_marker: &'static str,
239    pub feature_gate: &'static str,
240}
241
242impl Default for StubSmtBackendV0 {
243    fn default() -> Self {
244        Self {
245            schema_version: SMT_SCHEMA_VERSION_V0,
246            product: "omena-smt.backend.stub",
247            layer_marker: SMT_LAYER_MARKER_V0,
248            feature_gate: SMT_FEATURE_GATE_V0,
249        }
250    }
251}
252
253impl SmtBackendV0 for StubSmtBackendV0 {
254    fn backend_kind(&self) -> SmtBackendKindV0 {
255        SmtBackendKindV0::Stub
256    }
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
260#[serde(rename_all = "camelCase")]
261pub enum SmtVerdictV0 {
262    Accepted,
263    Rejected,
264    Unknown,
265}
266
267#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
268#[serde(rename_all = "camelCase")]
269pub struct CascadeSMTProofV0 {
270    pub schema_version: &'static str,
271    pub product: &'static str,
272    pub layer_marker: &'static str,
273    pub feature_gate: &'static str,
274    pub obligation_id: String,
275    pub backend: SmtBackendKindV0,
276    pub verdict: SmtVerdictV0,
277    pub l1_primitive: &'static str,
278    pub l1_accepted: Option<bool>,
279    pub canonical_input: CanonicalSmtInputV0,
280    pub solver_check: SmtBackendCheckV0,
281    pub refinement_verdict: Option<RefinementVerdictV0>,
282    pub cascade_spec_digest: [u8; 32],
283}
284
285impl CascadeSMTProofV0 {
286    pub fn evidence_node_key(&self) -> EvidenceNodeKeyV0 {
287        EvidenceNodeKeyV0::new(
288            CASCADE_PROOF_RECORD_EVIDENCE_QUERY_V0,
289            self.obligation_id.clone(),
290        )
291    }
292
293    pub fn evidence_node_seed(&self) -> EvidenceNodeSeedV0 {
294        let provenance = vec![
295            ["obligation:", self.obligation_id.as_str()].concat(),
296            ["primitive:", self.l1_primitive].concat(),
297            ["featureGate:", self.feature_gate].concat(),
298        ];
299        let lookup = lookup_discharge_ledger_entry_v0(&self.canonical_input);
300        if let Some(seed) = ledger_backed_cascade_proof_seed_v0(
301            self.evidence_node_key(),
302            provenance.clone(),
303            &lookup,
304        ) {
305            return seed;
306        }
307        let family_stamp = prose_obligation_family_stamp(&provenance);
308        EvidenceNodeSeedV0::with_family(
309            self.evidence_node_key(),
310            provenance,
311            GuaranteeKindV0::for_label_less_family(),
312            family_stamp,
313        )
314    }
315
316    pub fn evidence_graph(&self) -> Result<EvidenceGraphV0, EvidenceGraphBuildErrorV0> {
317        build_evidence_graph_from_edges_v0(
318            [self.evidence_node_seed()],
319            [EvidenceDemandEdgeV0::new(
320                CASCADE_PROOF_RECORD_EVIDENCE_QUERY_V0,
321                self.evidence_node_key(),
322                CASCADE_PROOF_EVIDENCE_EDGE_KIND_V0,
323            )],
324        )
325    }
326}
327
328#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
329#[serde(rename_all = "camelCase")]
330pub struct TransformRewriteProofInputV0 {
331    pub schema_version: &'static str,
332    pub product: &'static str,
333    pub pass_id: String,
334    pub cascade_obligation_declared: bool,
335    pub provenance_recomputed: bool,
336    pub provenance_preserved: bool,
337    pub contains_bogus_or_trivia: bool,
338    pub stable_post_semantic_ir: bool,
339}
340
341impl TransformRewriteProofInputV0 {
342    pub fn new(
343        pass_id: impl Into<String>,
344        obligation_family: ObligationFamilyIdV0,
345        provenance_recomputed: bool,
346        provenance_preserved: bool,
347        contains_bogus_or_trivia: bool,
348        stable_post_semantic_ir: bool,
349    ) -> Self {
350        let cascade_obligation_declared = obligation_family.declares_cascade_obligation();
351        Self {
352            schema_version: SMT_SCHEMA_VERSION_V0,
353            product: "omena-cascade-proof.transform-rewrite-input",
354            pass_id: pass_id.into(),
355            cascade_obligation_declared,
356            provenance_recomputed,
357            provenance_preserved,
358            contains_bogus_or_trivia,
359            stable_post_semantic_ir,
360        }
361    }
362
363    pub fn evidence_node_key(&self) -> EvidenceNodeKeyV0 {
364        EvidenceNodeKeyV0::new(REWRITE_PROOF_INPUT_EVIDENCE_QUERY_V0, self.pass_id.clone())
365    }
366
367    pub fn evidence_node_seed(&self) -> EvidenceNodeSeedV0 {
368        let provenance = vec![
369            ["pass:", self.pass_id.as_str()].concat(),
370            [
371                "cascadeObligationDeclared:",
372                self.cascade_obligation_declared.to_string().as_str(),
373            ]
374            .concat(),
375            [
376                "provenanceRecomputed:",
377                self.provenance_recomputed.to_string().as_str(),
378            ]
379            .concat(),
380            [
381                "provenancePreserved:",
382                self.provenance_preserved.to_string().as_str(),
383            ]
384            .concat(),
385        ];
386        let family_stamp = prose_obligation_family_stamp(&provenance);
387        EvidenceNodeSeedV0::with_family(
388            self.evidence_node_key(),
389            provenance,
390            GuaranteeKindV0::for_label_less_family(),
391            family_stamp,
392        )
393    }
394
395    pub fn evidence_graph(&self) -> Result<EvidenceGraphV0, EvidenceGraphBuildErrorV0> {
396        build_evidence_graph_from_edges_v0(
397            [self.evidence_node_seed()],
398            [EvidenceDemandEdgeV0::new(
399                REWRITE_PROOF_INPUT_EVIDENCE_QUERY_V0,
400                self.evidence_node_key(),
401                CASCADE_PROOF_EVIDENCE_EDGE_KIND_V0,
402            )],
403        )
404    }
405}
406
407fn ledger_backed_cascade_proof_seed_v0(
408    key: EvidenceNodeKeyV0,
409    mut provenance: Vec<String>,
410    lookup: &DischargeLedgerLookupV0,
411) -> Option<EvidenceNodeSeedV0> {
412    if !lookup.can_apply_family_stamp() {
413        return None;
414    }
415    let witness = LedgerDischargeWitnessV0::from_discharge_cell_key_v0(&lookup.cell_key)?;
416    provenance.push(["dischargeCell:", lookup.cell_key.as_str()].concat());
417    Some(EvidenceNodeSeedV0::with_family(
418        key,
419        provenance,
420        GuaranteeKindV0::for_label_less_family(),
421        FamilyStampV0::ledger_backed_obligation_discharge(&witness),
422    ))
423}
424
425pub fn cascade_spec_digest_v0() -> [u8; 32] {
426    *blake3::hash(CASCADE_SMT_SPEC_MATERIAL_V0.as_bytes()).as_bytes()
427}
428
429fn cascade_smt_proof_v0<B: SmtBackendV0>(
430    canonical_input: CanonicalSmtInputV0,
431    backend: &B,
432    l1_primitive: &'static str,
433    l1_accepted: Option<bool>,
434) -> CascadeSMTProofV0 {
435    let solver_check = backend.check_canonical_input_v0(&canonical_input);
436    CascadeSMTProofV0 {
437        schema_version: SMT_SCHEMA_VERSION_V0,
438        product: "omena-smt.cascade-proof",
439        layer_marker: SMT_LAYER_MARKER_V0,
440        feature_gate: SMT_FEATURE_GATE_V0,
441        obligation_id: canonical_input.obligation_id.clone(),
442        backend: backend.backend_kind(),
443        verdict: smt_verdict_from_backend_check_v0(solver_check.sat_result),
444        l1_primitive,
445        l1_accepted,
446        canonical_input,
447        solver_check,
448        refinement_verdict: None,
449        cascade_spec_digest: cascade_spec_digest_v0(),
450    }
451}
452
453fn smt_verdict_from_backend_check_v0(sat_result: SmtBackendSatResultV0) -> SmtVerdictV0 {
454    match sat_result {
455        SmtBackendSatResultV0::Sat => SmtVerdictV0::Accepted,
456        SmtBackendSatResultV0::Unsat => SmtVerdictV0::Rejected,
457        SmtBackendSatResultV0::Unknown => SmtVerdictV0::Unknown,
458    }
459}
460
461pub fn smt_prove_box_shorthand_combination_v0<B: SmtBackendV0>(
462    shorthand_property: &str,
463    longhands: &[BoxLonghandInputV0],
464    backend: &B,
465) -> CascadeSMTProofV0 {
466    let proof = prove_box_shorthand_combination(shorthand_property, longhands);
467    let canonical_input =
468        canonical_box_shorthand_combination_input_v0(shorthand_property, longhands);
469    cascade_smt_proof_v0(
470        canonical_input,
471        backend,
472        "prove_box_shorthand_combination",
473        Some(proof.accepted),
474    )
475}
476
477pub fn smt_prove_longhand_merge_v0<B, S>(
478    shorthand_property: &str,
479    expected_longhands: &[S],
480    longhands: &[LonghandMergeInputV0],
481    backend: &B,
482) -> CascadeSMTProofV0
483where
484    B: SmtBackendV0,
485    S: AsRef<str>,
486{
487    let proof = prove_longhand_merge(shorthand_property, expected_longhands, longhands);
488    let canonical_input =
489        canonical_longhand_merge_input_v0(shorthand_property, expected_longhands, longhands);
490    cascade_smt_proof_v0(
491        canonical_input,
492        backend,
493        "prove_longhand_merge",
494        Some(proof.accepted),
495    )
496}
497
498pub fn smt_prove_scope_flatten_candidate_v0<B: SmtBackendV0>(
499    input: ScopeFlattenInputV0,
500    backend: &B,
501) -> CascadeSMTProofV0 {
502    let canonical_input = canonical_scope_flatten_candidate_input_v0(&input);
503    let proof = prove_scope_flatten_candidate(input);
504    cascade_smt_proof_v0(
505        canonical_input,
506        backend,
507        "prove_scope_flatten_candidate",
508        Some(proof.accepted),
509    )
510}
511
512pub fn smt_prove_layer_flatten_candidate_v0<B: SmtBackendV0>(
513    input: LayerFlattenInputV0,
514    backend: &B,
515) -> CascadeSMTProofV0 {
516    let canonical_input = canonical_layer_flatten_candidate_input_v0(&input);
517    let proof = prove_layer_flatten_candidate(input);
518    cascade_smt_proof_v0(
519        canonical_input,
520        backend,
521        "prove_layer_flatten_candidate",
522        Some(proof.accepted),
523    )
524}
525
526pub fn smt_evaluate_static_supports_condition_v0<B: SmtBackendV0>(
527    condition: &str,
528    assumption: StaticSupportsAssumptionV0,
529    backend: &B,
530) -> CascadeSMTProofV0 {
531    let witness = evaluate_static_supports_condition(condition, assumption);
532    let l1_accepted = match witness.verdict {
533        StaticSupportsEvalVerdictV0::AlwaysTrue => Some(true),
534        StaticSupportsEvalVerdictV0::AlwaysFalse => Some(false),
535        StaticSupportsEvalVerdictV0::Unknown => None,
536    };
537    cascade_smt_proof_v0(
538        canonical_static_supports_condition_input_v0(&witness.verdict),
539        backend,
540        "evaluate_static_supports_condition",
541        l1_accepted,
542    )
543}
544
545pub fn smt_verify_transform_rewrite_candidate_v0<B: SmtBackendV0>(
546    input: &TransformRewriteProofInputV0,
547    backend: &B,
548) -> CascadeSMTProofV0 {
549    cascade_smt_proof_v0(
550        canonical_transform_rewrite_candidate_input_v0(input),
551        backend,
552        "verify_transform_rewrite_candidate",
553        Some(
554            input.cascade_obligation_declared
555                && input.provenance_recomputed
556                && input.provenance_preserved
557                && !input.contains_bogus_or_trivia
558                && input.stable_post_semantic_ir,
559        ),
560    )
561}
562
563fn canonical_box_shorthand_combination_input_v0(
564    shorthand_property: &str,
565    longhands: &[BoxLonghandInputV0],
566) -> CanonicalSmtInputV0 {
567    let expected = smt_box_shorthand_longhands_v0(shorthand_property);
568    let canonical_order = expected.is_some_and(|expected| {
569        longhands.len() == expected.len()
570            && longhands
571                .iter()
572                .zip(expected.iter())
573                .all(|(actual, expected)| actual.property == *expected)
574    });
575    canonical_smt_input_v0(
576        "box-shorthand-combination",
577        "prove_box_shorthand_combination",
578        vec![
579            smt_ir_computed_requirement_v0("supported-shorthand-property", expected.is_some()),
580            smt_ir_computed_requirement_v0("canonical-longhand-quartet", canonical_order),
581            smt_ir_computed_requirement_v0(
582                "no-important-longhand",
583                longhands.iter().all(|longhand| !longhand.important),
584            ),
585            smt_ir_computed_requirement_v0(
586                "no-empty-longhand-value",
587                longhands.iter().all(|longhand| !longhand.value.is_empty()),
588            ),
589            smt_ir_computed_requirement_v0(
590                "adjacent-source-order",
591                longhands
592                    .windows(2)
593                    .all(|pair| pair[1].source_order == pair[0].source_order + 1),
594            ),
595        ],
596    )
597}
598
599fn canonical_longhand_merge_input_v0<S>(
600    shorthand_property: &str,
601    expected_longhands: &[S],
602    longhands: &[LonghandMergeInputV0],
603) -> CanonicalSmtInputV0
604where
605    S: AsRef<str>,
606{
607    let canonical_order = !expected_longhands.is_empty()
608        && longhands.len() == expected_longhands.len()
609        && longhands
610            .iter()
611            .zip(expected_longhands.iter())
612            .all(|(actual, expected)| actual.property == expected.as_ref());
613    canonical_smt_input_v0(
614        "longhand-merge",
615        "prove_longhand_merge",
616        vec![
617            smt_ir_computed_requirement_v0(
618                "supported-merge-family",
619                !expected_longhands.is_empty(),
620            ),
621            smt_ir_computed_requirement_v0("canonical-longhand-order", canonical_order),
622            smt_ir_computed_requirement_v0(
623                "no-important-longhand",
624                longhands.iter().all(|longhand| !longhand.important),
625            ),
626            smt_ir_computed_requirement_v0(
627                "no-empty-longhand-value",
628                longhands.iter().all(|longhand| !longhand.value.is_empty()),
629            ),
630            smt_ir_computed_requirement_v0(
631                "adjacent-source-order",
632                longhands
633                    .windows(2)
634                    .all(|pair| pair[1].source_order == pair[0].source_order + 1),
635            ),
636            format!("merge-family:{shorthand_property}"),
637        ],
638    )
639}
640
641fn canonical_scope_flatten_candidate_input_v0(input: &ScopeFlattenInputV0) -> CanonicalSmtInputV0 {
642    canonical_smt_input_v0(
643        "scope-flatten-candidate",
644        "prove_scope_flatten_candidate",
645        vec![
646            smt_ir_computed_requirement_v0("no-limit-selector", input.limit_selector.is_none()),
647            smt_ir_computed_requirement_v0("root-scope", input.root_selector.trim() == ":root"),
648            smt_ir_computed_requirement_v0("no-peer-scope", input.peer_scope_count == 0),
649            smt_ir_computed_requirement_v0(
650                "no-competing-unscoped-rule",
651                input.competing_unscoped_rule_count == 0,
652            ),
653            smt_ir_computed_requirement_v0("not-inside-layer", !input.inside_layer),
654        ],
655    )
656}
657
658fn canonical_layer_flatten_candidate_input_v0(input: &LayerFlattenInputV0) -> CanonicalSmtInputV0 {
659    canonical_smt_input_v0(
660        "layer-flatten-candidate",
661        "prove_layer_flatten_candidate",
662        vec![
663            smt_producer_pass_through_requirement_v0("closed-bundle", input.closed_bundle),
664            smt_ir_computed_requirement_v0("no-peer-layer", input.peer_layer_count == 0),
665            smt_ir_computed_requirement_v0("no-unlayered-rule", input.unlayered_rule_count == 0),
666            smt_ir_computed_requirement_v0(
667                "no-important-declaration",
668                input.important_declaration_count == 0,
669            ),
670        ],
671    )
672}
673
674fn canonical_static_supports_condition_input_v0(
675    verdict: &StaticSupportsEvalVerdictV0,
676) -> CanonicalSmtInputV0 {
677    let canonical_terms = match verdict {
678        StaticSupportsEvalVerdictV0::AlwaysTrue => {
679            vec![smt_ir_computed_requirement_v0(
680                "supports-condition-known-true",
681                true,
682            )]
683        }
684        StaticSupportsEvalVerdictV0::AlwaysFalse => {
685            vec![smt_ir_computed_requirement_v0(
686                "supports-condition-known-true",
687                false,
688            )]
689        }
690        StaticSupportsEvalVerdictV0::Unknown => vec!["unknown:supports-condition".to_string()],
691    };
692    canonical_smt_input_v0(
693        "static-supports-condition",
694        "evaluate_static_supports_condition",
695        canonical_terms,
696    )
697}
698
699fn canonical_transform_rewrite_candidate_input_v0(
700    input: &TransformRewriteProofInputV0,
701) -> CanonicalSmtInputV0 {
702    canonical_smt_input_v0(
703        "transform-rewrite-candidate",
704        "verify_transform_rewrite_candidate",
705        vec![
706            format!("pass:{}", input.pass_id),
707            smt_producer_pass_through_requirement_v0(
708                "cascade-obligation-declared",
709                input.cascade_obligation_declared,
710            ),
711            smt_ir_computed_requirement_v0("provenance-recomputed", input.provenance_recomputed),
712            smt_ir_computed_requirement_v0("provenance-preserved", input.provenance_preserved),
713            smt_ir_computed_requirement_v0("no-bogus-or-trivia", !input.contains_bogus_or_trivia),
714            smt_ir_computed_requirement_v0(
715                "stable-post-semantic-ir",
716                input.stable_post_semantic_ir,
717            ),
718        ],
719    )
720}
721
722fn smt_require_term_v0(name: &str, value: bool) -> String {
723    format!("require:{name}={value}")
724}
725
726/// Record a requirement whose Boolean was recomputed from product IR, source
727/// syntax, or a product evaluator before this canonical input was assembled.
728fn smt_ir_computed_requirement_v0(name: &str, value: bool) -> String {
729    smt_require_term_v0(name, value)
730}
731
732/// Record a requirement copied from a producer-owned declaration or witness.
733///
734/// The default backend only parses this Boolean back out of the string; this
735/// label is disclosure, not additional semantic strength.
736fn smt_producer_pass_through_requirement_v0(name: &str, value: bool) -> String {
737    smt_require_term_v0(name, value)
738}
739
740fn smt_box_shorthand_longhands_v0(shorthand_property: &str) -> Option<[&'static str; 4]> {
741    match shorthand_property {
742        "margin" => Some(["margin-top", "margin-right", "margin-bottom", "margin-left"]),
743        "padding" => Some([
744            "padding-top",
745            "padding-right",
746            "padding-bottom",
747            "padding-left",
748        ]),
749        "border-color" => Some([
750            "border-top-color",
751            "border-right-color",
752            "border-bottom-color",
753            "border-left-color",
754        ]),
755        "border-style" => Some([
756            "border-top-style",
757            "border-right-style",
758            "border-bottom-style",
759            "border-left-style",
760        ]),
761        "border-width" => Some([
762            "border-top-width",
763            "border-right-width",
764            "border-bottom-width",
765            "border-left-width",
766        ]),
767        "scroll-margin" => Some([
768            "scroll-margin-top",
769            "scroll-margin-right",
770            "scroll-margin-bottom",
771            "scroll-margin-left",
772        ]),
773        "scroll-padding" => Some([
774            "scroll-padding-top",
775            "scroll-padding-right",
776            "scroll-padding-bottom",
777            "scroll-padding-left",
778        ]),
779        _ => None,
780    }
781}
782
783#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
784#[serde(rename_all = "camelCase")]
785pub struct LayerInversionDeclarationV0 {
786    pub schema_version: &'static str,
787    pub product: &'static str,
788    pub layer_marker: &'static str,
789    pub feature_gate: &'static str,
790    pub declaration_id: String,
791    pub layer_rank: i64,
792    pub source_order: i64,
793}
794
795pub fn layer_inversion_declaration_v0(
796    declaration_id: impl Into<String>,
797    layer_rank: i64,
798    source_order: i64,
799) -> LayerInversionDeclarationV0 {
800    LayerInversionDeclarationV0 {
801        schema_version: SMT_SCHEMA_VERSION_V0,
802        product: "omena-smt.layer-inversion-declaration",
803        layer_marker: SMT_LAYER_MARKER_V0,
804        feature_gate: SMT_FEATURE_GATE_V0,
805        declaration_id: declaration_id.into(),
806        layer_rank,
807        source_order,
808    }
809}
810
811#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
812#[serde(rename_all = "camelCase")]
813pub struct LayerFlattenInversionVerdictV0 {
814    pub schema_version: &'static str,
815    pub product: &'static str,
816    pub layer_marker: &'static str,
817    pub feature_gate: &'static str,
818    pub backend: SmtBackendKindV0,
819    pub inversion_exists: bool,
820    pub verdict: SmtVerdictV0,
821    pub canonical_input: CanonicalSmtInputV0,
822    pub sat_result: SmtBackendSatResultV0,
823}
824
825impl LayerFlattenInversionVerdictV0 {
826    pub fn evidence_node_key(&self) -> EvidenceNodeKeyV0 {
827        EvidenceNodeKeyV0::new(
828            CASCADE_PROOF_RECORD_EVIDENCE_QUERY_V0,
829            self.canonical_input.obligation_id.clone(),
830        )
831    }
832
833    pub fn evidence_node_seed(&self) -> EvidenceNodeSeedV0 {
834        let provenance = vec![
835            ["obligation:", self.canonical_input.obligation_id.as_str()].concat(),
836            ["primitive:", self.canonical_input.l1_primitive].concat(),
837            ["featureGate:", self.feature_gate].concat(),
838        ];
839        let lookup = lookup_discharge_ledger_entry_v0(&self.canonical_input);
840        if let Some(seed) = ledger_backed_cascade_proof_seed_v0(
841            self.evidence_node_key(),
842            provenance.clone(),
843            &lookup,
844        ) {
845            return seed;
846        }
847        let family_stamp = prose_obligation_family_stamp(&provenance);
848        EvidenceNodeSeedV0::with_family(
849            self.evidence_node_key(),
850            provenance,
851            GuaranteeKindV0::for_label_less_family(),
852            family_stamp,
853        )
854    }
855
856    pub fn evidence_graph(&self) -> Result<EvidenceGraphV0, EvidenceGraphBuildErrorV0> {
857        build_evidence_graph_from_edges_v0(
858            [self.evidence_node_seed()],
859            [EvidenceDemandEdgeV0::new(
860                CASCADE_PROOF_RECORD_EVIDENCE_QUERY_V0,
861                self.evidence_node_key(),
862                CASCADE_PROOF_EVIDENCE_EDGE_KIND_V0,
863            )],
864        )
865    }
866}
867
868pub fn canonical_layer_flatten_inversion_input_v0(
869    declarations: &[LayerInversionDeclarationV0],
870) -> CanonicalSmtInputV0 {
871    let declarations = canonicalize_layer_inversion_declarations_v0(declarations);
872    let mut script = String::from("(set-logic QF_LIA)\n");
873    for (index, declaration) in declarations.iter().enumerate() {
874        script.push_str(&format!("(declare-const rank_{index} Int)\n"));
875        script.push_str(&format!("(declare-const source_{index} Int)\n"));
876        script.push_str(&format!(
877            "(assert (= rank_{index} {}))\n",
878            smtlib2_int_v0(declaration.layer_rank)
879        ));
880        script.push_str(&format!(
881            "(assert (= source_{index} {}))\n",
882            smtlib2_int_v0(declaration.source_order)
883        ));
884    }
885
886    let mut inversion_clauses = Vec::new();
887    for a in 0..declarations.len() {
888        for b in 0..declarations.len() {
889            if a == b {
890                continue;
891            }
892            inversion_clauses.push(format!(
893                "(and (> rank_{a} rank_{b}) (> source_{b} source_{a}))"
894            ));
895        }
896    }
897
898    let inversion_assertion = match inversion_clauses.len() {
899        0 => "false".to_string(),
900        1 => inversion_clauses.remove(0),
901        _ => format!("(or {})", inversion_clauses.join(" ")),
902    };
903    script.push_str(&format!(
904        "(assert (! {inversion_assertion} :named cascade_layer_flatten_inversion))\n"
905    ));
906
907    let canonical_terms = declarations
908        .iter()
909        .map(|declaration| {
910            format!(
911                "decl:{}:rank={}:source={}",
912                declaration.declaration_id, declaration.layer_rank, declaration.source_order
913            )
914        })
915        .collect();
916
917    canonical_smt_input_with_script_v0(
918        "layer-flatten-cascade-inversion",
919        "prove_layer_flatten_candidate",
920        canonical_terms,
921        script,
922    )
923}
924
925pub fn canonicalize_layer_inversion_declarations_v0(
926    declarations: &[LayerInversionDeclarationV0],
927) -> Vec<LayerInversionDeclarationV0> {
928    let mut layer_ranks = declarations
929        .iter()
930        .map(|declaration| declaration.layer_rank)
931        .collect::<Vec<_>>();
932    layer_ranks.sort_unstable();
933    layer_ranks.dedup();
934    let mut source_orders = declarations
935        .iter()
936        .map(|declaration| declaration.source_order)
937        .collect::<Vec<_>>();
938    source_orders.sort_unstable();
939    source_orders.dedup();
940
941    declarations
942        .iter()
943        .enumerate()
944        .map(|(index, declaration)| {
945            layer_inversion_declaration_v0(
946                format!("decl-{index}"),
947                ordinal_coordinate(declaration.layer_rank, &layer_ranks),
948                ordinal_coordinate(declaration.source_order, &source_orders),
949            )
950        })
951        .collect()
952}
953
954fn ordinal_coordinate(value: i64, ordered_values: &[i64]) -> i64 {
955    let index = ordered_values
956        .binary_search(&value)
957        .unwrap_or_else(|index| index) as i64;
958    index - (ordered_values.len().saturating_sub(1) as i64 / 2)
959}
960
961pub fn smt_check_layer_flatten_inversion_v0<B: SmtBackendV0>(
962    declarations: &[LayerInversionDeclarationV0],
963    backend: &B,
964) -> LayerFlattenInversionVerdictV0 {
965    let canonical_input = canonical_layer_flatten_inversion_input_v0(declarations);
966    let check = backend.check_canonical_input_v0(&canonical_input);
967    let inversion_exists = matches!(check.sat_result, SmtBackendSatResultV0::Sat);
968    let verdict = match check.sat_result {
969        SmtBackendSatResultV0::Sat => SmtVerdictV0::Rejected,
970        SmtBackendSatResultV0::Unsat => SmtVerdictV0::Accepted,
971        SmtBackendSatResultV0::Unknown => SmtVerdictV0::Unknown,
972    };
973    LayerFlattenInversionVerdictV0 {
974        schema_version: SMT_SCHEMA_VERSION_V0,
975        product: "omena-smt.layer-flatten-inversion",
976        layer_marker: SMT_LAYER_MARKER_V0,
977        feature_gate: SMT_FEATURE_GATE_V0,
978        backend: backend.backend_kind(),
979        inversion_exists,
980        verdict,
981        canonical_input,
982        sat_result: check.sat_result,
983    }
984}
985
986fn smtlib2_int_v0(value: i64) -> String {
987    if value < 0 {
988        format!("(- {})", value.unsigned_abs())
989    } else {
990        value.to_string()
991    }
992}
993
994#[cfg(test)]
995mod tests {
996    use super::*;
997    use omena_cascade::{
998        StaticSupportsEvalVerdictV0, evaluate_static_supports_condition,
999        prove_box_shorthand_combination, prove_layer_flatten_candidate,
1000        prove_scope_flatten_candidate,
1001    };
1002    use omena_evidence_graph::GuaranteeFamilyV0;
1003
1004    fn accepted_verdict(accepted: bool) -> SmtVerdictV0 {
1005        if accepted {
1006            SmtVerdictV0::Accepted
1007        } else {
1008            SmtVerdictV0::Rejected
1009        }
1010    }
1011
1012    #[test]
1013    fn requirement_origin_labels_preserve_wire_and_non_require_terms_are_neutral() {
1014        assert_eq!(
1015            smt_ir_computed_requirement_v0("from-ir", true),
1016            "require:from-ir=true"
1017        );
1018        assert_eq!(
1019            smt_producer_pass_through_requirement_v0("from-producer", false),
1020            "require:from-producer=false"
1021        );
1022
1023        let neutral_audit_label = canonical_smt_input_v0(
1024            "classification-control",
1025            "classification_control",
1026            vec![
1027                smt_ir_computed_requirement_v0("from-ir", true),
1028                "pass:not-a-requirement".to_owned(),
1029            ],
1030        );
1031        let rejected_pass_through = canonical_smt_input_v0(
1032            "classification-negative",
1033            "classification_control",
1034            vec![smt_producer_pass_through_requirement_v0(
1035                "from-producer",
1036                false,
1037            )],
1038        );
1039        let backend = StubSmtBackendV0::default();
1040
1041        assert_eq!(
1042            backend
1043                .check_canonical_input_v0(&neutral_audit_label)
1044                .sat_result,
1045            SmtBackendSatResultV0::Sat
1046        );
1047        assert_eq!(
1048            backend
1049                .check_canonical_input_v0(&rejected_pass_through)
1050                .sat_result,
1051            SmtBackendSatResultV0::Unsat
1052        );
1053    }
1054
1055    #[test]
1056    fn default_backend_matches_l1_box_shorthand_verdict() {
1057        let backend = StubSmtBackendV0::default();
1058        let proof = smt_prove_box_shorthand_combination_v0(
1059            "margin",
1060            &[
1061                BoxLonghandInputV0 {
1062                    property: "margin-top".to_string(),
1063                    value: "1px".to_string(),
1064                    important: false,
1065                    source_order: 1,
1066                },
1067                BoxLonghandInputV0 {
1068                    property: "margin-right".to_string(),
1069                    value: "1px".to_string(),
1070                    important: false,
1071                    source_order: 2,
1072                },
1073                BoxLonghandInputV0 {
1074                    property: "margin-bottom".to_string(),
1075                    value: "1px".to_string(),
1076                    important: false,
1077                    source_order: 3,
1078                },
1079                BoxLonghandInputV0 {
1080                    property: "margin-left".to_string(),
1081                    value: "1px".to_string(),
1082                    important: false,
1083                    source_order: 4,
1084                },
1085            ],
1086            &backend,
1087        );
1088        assert_eq!(proof.schema_version, "0");
1089        assert_eq!(proof.verdict, SmtVerdictV0::Accepted);
1090        assert_eq!(proof.backend, SmtBackendKindV0::Stub);
1091        assert!(
1092            proof
1093                .canonical_input
1094                .smtlib2_script
1095                .contains("(set-logic QF_UF)")
1096        );
1097    }
1098
1099    #[test]
1100    fn transform_rewrite_verification_runs_backend_check() {
1101        let backend = StubSmtBackendV0::default();
1102        let proof_input = TransformRewriteProofInputV0::new(
1103            "rule-deduplication",
1104            ObligationFamilyIdV0::CascadeObligationDeclaration,
1105            true,
1106            true,
1107            false,
1108            true,
1109        );
1110        let proof = smt_verify_transform_rewrite_candidate_v0(&proof_input, &backend);
1111
1112        assert_eq!(proof.verdict, SmtVerdictV0::Accepted);
1113        assert_eq!(proof.l1_primitive, "verify_transform_rewrite_candidate");
1114        assert_eq!(
1115            proof.canonical_input.obligation_id,
1116            "transform-rewrite-candidate"
1117        );
1118        assert!(
1119            proof
1120                .canonical_input
1121                .canonical_terms
1122                .contains(&"require:provenance-recomputed=true".to_string())
1123        );
1124        assert!(
1125            proof
1126                .canonical_input
1127                .canonical_terms
1128                .contains(&"require:no-bogus-or-trivia=true".to_string())
1129        );
1130    }
1131
1132    #[test]
1133    fn proof_style_bisimulation_invariant_holds_for_all_l1_primitives() {
1134        let backend = StubSmtBackendV0::default();
1135        let longhands = vec![
1136            BoxLonghandInputV0 {
1137                property: "margin-top".to_string(),
1138                value: "1px".to_string(),
1139                important: false,
1140                source_order: 1,
1141            },
1142            BoxLonghandInputV0 {
1143                property: "margin-right".to_string(),
1144                value: "1px".to_string(),
1145                important: false,
1146                source_order: 2,
1147            },
1148            BoxLonghandInputV0 {
1149                property: "margin-bottom".to_string(),
1150                value: "1px".to_string(),
1151                important: false,
1152                source_order: 3,
1153            },
1154            BoxLonghandInputV0 {
1155                property: "margin-left".to_string(),
1156                value: "1px".to_string(),
1157                important: false,
1158                source_order: 4,
1159            },
1160        ];
1161        let l1_box = prove_box_shorthand_combination("margin", &longhands);
1162        let l3_box = smt_prove_box_shorthand_combination_v0("margin", &longhands, &backend);
1163        assert_eq!(l3_box.verdict, accepted_verdict(l1_box.accepted));
1164
1165        let scope_input = ScopeFlattenInputV0 {
1166            root_selector: ":root".to_string(),
1167            limit_selector: None,
1168            scoped_rule_count: 1,
1169            peer_scope_count: 0,
1170            competing_unscoped_rule_count: 0,
1171            inside_layer: false,
1172        };
1173        let l1_scope = prove_scope_flatten_candidate(scope_input.clone());
1174        let l3_scope = smt_prove_scope_flatten_candidate_v0(scope_input, &backend);
1175        assert_eq!(l3_scope.verdict, accepted_verdict(l1_scope.accepted));
1176
1177        let layer_input = LayerFlattenInputV0 {
1178            layer_name: Some("components".to_string()),
1179            layer_rule_count: 1,
1180            peer_layer_count: 0,
1181            unlayered_rule_count: 0,
1182            important_declaration_count: 0,
1183            closed_bundle: true,
1184        };
1185        let l1_layer = prove_layer_flatten_candidate(layer_input.clone());
1186        let l3_layer = smt_prove_layer_flatten_candidate_v0(layer_input, &backend);
1187        assert_eq!(l3_layer.verdict, accepted_verdict(l1_layer.accepted));
1188    }
1189
1190    #[test]
1191    fn static_supports_smt_equivalence_tracks_l1_verdict_shape() {
1192        let backend = StubSmtBackendV0::default();
1193        let l1 = evaluate_static_supports_condition(
1194            "(display: grid)",
1195            StaticSupportsAssumptionV0::ModernBrowser,
1196        );
1197        let l3 = smt_evaluate_static_supports_condition_v0(
1198            "(display: grid)",
1199            StaticSupportsAssumptionV0::ModernBrowser,
1200            &backend,
1201        );
1202
1203        assert_eq!(l1.verdict, StaticSupportsEvalVerdictV0::AlwaysTrue);
1204        assert_eq!(l3.verdict, SmtVerdictV0::Accepted);
1205        assert_eq!(l3.l1_primitive, "evaluate_static_supports_condition");
1206    }
1207
1208    #[test]
1209    fn smt_bisimulation_fuzz_seed_corpus_covers_fixture_shapes() {
1210        let report = run_smt_bisimulation_fuzz_seed_corpus_v0(128);
1211        assert_eq!(report.schema_version, "0");
1212        assert_eq!(report.fixture_suite, "m3-cascade-proof-fixtures");
1213        assert_eq!(report.checked_obligation_count, 128 * 4);
1214        assert_eq!(report.l1_l3_mismatch_count, 0);
1215        assert!(report.passed);
1216    }
1217
1218    #[test]
1219    fn smt_bisimulation_fuzz_case_is_a_schema_zero_contract() {
1220        let case = smt_bisimulation_fuzz_case_v0(42);
1221        assert_eq!(case.schema_version, "0");
1222        assert_eq!(case.layer_marker, "smt-cascade-verification");
1223        assert_eq!(case.feature_gate, "smt-stub");
1224        assert_eq!(case.seed, 42);
1225    }
1226
1227    #[test]
1228    fn rewrite_proof_input_evidence_graph_preserves_public_shape() -> Result<(), serde_json::Error>
1229    {
1230        let input = TransformRewriteProofInputV0::new(
1231            "number-compression",
1232            ObligationFamilyIdV0::CascadeObligationDeclaration,
1233            true,
1234            true,
1235            false,
1236            true,
1237        );
1238
1239        let before = serde_json::to_value(&input)?;
1240        let graph = input
1241            .evidence_graph()
1242            .map_err(|_| serde::ser::Error::custom("input edge must target its node"))?;
1243        let after = serde_json::to_value(&input)?;
1244
1245        assert_eq!(before, after);
1246        assert_eq!(graph.nodes.len(), 1);
1247        assert_eq!(graph.nodes[0].key.input_identity, "number-compression");
1248        assert_eq!(graph.nodes[0].guarantee, GuaranteeKindV0::Floor);
1249        assert_eq!(
1250            graph.nodes[0].earned_via(),
1251            GuaranteeFamilyV0::ProseObligationDischarged
1252        );
1253        assert!(
1254            graph.nodes[0]
1255                .provenance
1256                .iter()
1257                .any(|item| item == "provenancePreserved:true")
1258        );
1259        Ok(())
1260    }
1261
1262    #[test]
1263    fn rewrite_proof_input_family_derivation_preserves_legacy_json_contract()
1264    -> Result<(), serde_json::Error> {
1265        for (pass_id, family, expected_declared) in [
1266            (
1267                "number-compression",
1268                ObligationFamilyIdV0::CascadeObligationDeclaration,
1269                true,
1270            ),
1271            ("print-css", ObligationFamilyIdV0::CascadeSafetyFloor, false),
1272        ] {
1273            let input =
1274                TransformRewriteProofInputV0::new(pass_id, family, true, false, false, true);
1275
1276            assert_eq!(
1277                serde_json::to_value(&input)?,
1278                serde_json::json!({
1279                    "schemaVersion": "0",
1280                    "product": "omena-cascade-proof.transform-rewrite-input",
1281                    "passId": pass_id,
1282                    "cascadeObligationDeclared": expected_declared,
1283                    "provenanceRecomputed": true,
1284                    "provenancePreserved": false,
1285                    "containsBogusOrTrivia": false,
1286                    "stablePostSemanticIr": true,
1287                })
1288            );
1289            assert_eq!(
1290                serde_json::to_value(input.evidence_node_seed())?,
1291                serde_json::json!({
1292                    "key": {
1293                        "queryIdentity": REWRITE_PROOF_INPUT_EVIDENCE_QUERY_V0,
1294                        "inputIdentity": pass_id,
1295                    },
1296                    "provenance": [
1297                        format!("pass:{pass_id}"),
1298                        format!("cascadeObligationDeclared:{expected_declared}"),
1299                        "provenanceRecomputed:true",
1300                        "provenancePreserved:false",
1301                    ],
1302                    "guarantee": "floor",
1303                    "earnedVia": "proseObligationDischarged",
1304                })
1305            );
1306        }
1307
1308        Ok(())
1309    }
1310
1311    #[test]
1312    fn cascade_proof_record_evidence_graph_preserves_public_shape() -> Result<(), serde_json::Error>
1313    {
1314        let backend = StubSmtBackendV0::default();
1315        let proof = smt_verify_transform_rewrite_candidate_v0(
1316            &TransformRewriteProofInputV0::new(
1317                "number-compression",
1318                ObligationFamilyIdV0::CascadeObligationDeclaration,
1319                true,
1320                true,
1321                false,
1322                true,
1323            ),
1324            &backend,
1325        );
1326
1327        let before = serde_json::to_value(&proof)?;
1328        let graph = proof
1329            .evidence_graph()
1330            .map_err(|_| serde::ser::Error::custom("proof edge must target its node"))?;
1331        let after = serde_json::to_value(&proof)?;
1332
1333        assert_eq!(before, after);
1334        assert_eq!(graph.nodes.len(), 1);
1335        assert_eq!(graph.nodes[0].key.input_identity, proof.obligation_id);
1336        assert_eq!(graph.nodes[0].guarantee, GuaranteeKindV0::Floor);
1337        assert_eq!(
1338            graph.nodes[0].earned_via(),
1339            GuaranteeFamilyV0::ProseObligationDischarged
1340        );
1341        assert!(
1342            graph.nodes[0]
1343                .provenance
1344                .iter()
1345                .any(|item| item == "primitive:verify_transform_rewrite_candidate")
1346        );
1347        Ok(())
1348    }
1349
1350    #[test]
1351    fn cascade_proof_record_uses_ledger_family_on_matching_cell() -> Result<(), serde_json::Error> {
1352        let backend = StubSmtBackendV0::default();
1353        let longhands = vec![
1354            LonghandMergeInputV0 {
1355                property: "margin-top".to_string(),
1356                value: "1px".to_string(),
1357                important: false,
1358                source_order: 1,
1359            },
1360            LonghandMergeInputV0 {
1361                property: "margin-right".to_string(),
1362                value: "1px".to_string(),
1363                important: false,
1364                source_order: 2,
1365            },
1366            LonghandMergeInputV0 {
1367                property: "margin-bottom".to_string(),
1368                value: "1px".to_string(),
1369                important: false,
1370                source_order: 3,
1371            },
1372            LonghandMergeInputV0 {
1373                property: "margin-left".to_string(),
1374                value: "1px".to_string(),
1375                important: false,
1376                source_order: 4,
1377            },
1378        ];
1379        let proof = smt_prove_longhand_merge_v0(
1380            "margin",
1381            &["margin-top", "margin-right", "margin-bottom", "margin-left"],
1382            &longhands,
1383            &backend,
1384        );
1385        let graph = proof
1386            .evidence_graph()
1387            .map_err(|_| serde::ser::Error::custom("proof edge must target its node"))?;
1388
1389        assert_eq!(graph.nodes.len(), 1);
1390        assert_eq!(graph.nodes[0].guarantee, GuaranteeKindV0::Floor);
1391        assert_eq!(
1392            graph.nodes[0].earned_via(),
1393            GuaranteeFamilyV0::LedgerBackedObligationDischarge
1394        );
1395        assert!(
1396            graph.nodes[0]
1397                .provenance
1398                .iter()
1399                .any(|item| item.starts_with("dischargeCell:"))
1400        );
1401        Ok(())
1402    }
1403}