Skip to main content

fnx_cgse/
lib.rs

1//! # Canonical Graph Semantics Engine (CGSE)
2//!
3//! Deterministic tie-break policies with complexity witness artifacts per
4//! algorithm family. This is FrankenNetworkX's crown-jewel innovation: every
5//! algorithm declares its ordering policy at the type level, and every
6//! execution emits a cryptographic witness that proves which tie-break
7//! decisions were taken.
8//!
9//! ## Architecture
10//!
11//! - [`TieBreakPolicy`]: closed sum type with 13 named ordering variants.
12//! - [`ComplexityWitness`]: per-execution record carrying `(n, m, observed_ops,
13//!   policy_id, decision_path_blake3)`.
14//! - [`WitnessSink`]: thread-local collector that algorithms call on every
15//!   tie-break decision.
16//! - [`WitnessLedger`]: append-only log of witnesses, serializable to JSONL
17//!   with RaptorQ sidecar support.
18
19#![forbid(unsafe_code)]
20
21use serde::{Deserialize, Serialize};
22use std::cell::{Cell, RefCell};
23
24// ---------------------------------------------------------------------------
25// Tie-Break Policies
26// ---------------------------------------------------------------------------
27
28/// The 13 canonical tie-break orderings that NetworkX algorithms exhibit.
29///
30/// Each algorithm family declares exactly one policy. When two candidates
31/// are equally ranked by the algorithm's primary metric (cost, weight, degree,
32/// etc.), the policy determines which candidate is chosen.
33///
34/// The variant set is **closed** in V1 — users cannot define new policies.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum TieBreakPolicy {
38    /// Lexicographically smallest node/edge label wins.
39    LexMin,
40    /// Lexicographically largest node/edge label wins.
41    LexMax,
42    /// First-inserted candidate wins (adjacency-list order).
43    InsertionOrder,
44    /// Last-inserted candidate wins (reverse adjacency-list order).
45    ReverseInsertionOrder,
46    /// Primary: weight ascending; secondary: lex-min label.
47    WeightThenLex,
48    /// Primary: weight ascending; secondary: insertion order (FIFO).
49    ///
50    /// This is the policy NetworkX's heap-based weighted algorithms
51    /// actually use, via `heapq` + `itertools.count()` as a per-push
52    /// monotonic sequence counter. fnx's Dijkstra uses the same scheme
53    /// so equal-weight frontier entries pop in the order they were
54    /// pushed, matching NetworkX byte-for-byte.
55    WeightThenInsertionOrder,
56    /// Primary: lex-min label; secondary: weight ascending.
57    LexThenWeight,
58    /// Deterministic hash of (seed, label) — reproducible but order-agnostic.
59    DeterministicHash { seed: u64 },
60    /// Minimum-degree node wins; ties broken by lex-min label.
61    DegreeMinThenLex,
62    /// Maximum-degree node wins; ties broken by lex-min label.
63    DegreeMaxThenLex,
64    /// DFS pre-order traversal order.
65    DfsPreorder,
66    /// BFS level order, within-level lex-min.
67    BfsLevelLex,
68    /// Edge key lex-min (for multigraph algorithms).
69    EdgeKeyLex,
70}
71
72impl TieBreakPolicy {
73    /// Short stable identifier for serialization and ledger entries.
74    #[must_use]
75    pub fn id(&self) -> &'static str {
76        match self {
77            Self::LexMin => "lex_min",
78            Self::LexMax => "lex_max",
79            Self::InsertionOrder => "insertion_order",
80            Self::ReverseInsertionOrder => "reverse_insertion_order",
81            Self::WeightThenLex => "weight_then_lex",
82            Self::WeightThenInsertionOrder => "weight_then_insertion_order",
83            Self::LexThenWeight => "lex_then_weight",
84            Self::DeterministicHash { .. } => "deterministic_hash",
85            Self::DegreeMinThenLex => "degree_min_then_lex",
86            Self::DegreeMaxThenLex => "degree_max_then_lex",
87            Self::DfsPreorder => "dfs_preorder",
88            Self::BfsLevelLex => "bfs_level_lex",
89            Self::EdgeKeyLex => "edge_key_lex",
90        }
91    }
92
93    /// Sort a list of candidates according to the policy.
94    pub fn sort_candidates<T: AsRef<str>>(&self, candidates: &mut [T]) {
95        match self {
96            Self::LexMin => candidates.sort_unstable_by(|a, b| a.as_ref().cmp(b.as_ref())),
97            Self::LexMax => {
98                candidates.sort_unstable_by(|a, b| b.as_ref().cmp(a.as_ref()));
99            }
100            Self::InsertionOrder | Self::ReverseInsertionOrder => {
101                // These are governed by the underlying IndexMap order;
102                // typically no-op or handled at the iterator level.
103            }
104            Self::WeightThenLex | Self::WeightThenInsertionOrder | Self::LexThenWeight => {
105                // These require external weight data (and for
106                // WeightThenInsertionOrder, a monotonic insertion
107                // counter); handled by specialized priority-queue
108                // wrappers in fnx-algorithms (see DijkstraState's
109                // Ord impl for the canonical WeightThenInsertionOrder
110                // pattern).
111            }
112            Self::DeterministicHash { seed } => {
113                let s = *seed;
114                candidates.sort_unstable_by_key(|label| {
115                    let mut hasher = blake3::Hasher::new();
116                    hasher.update(&s.to_le_bytes());
117                    hasher.update(label.as_ref().as_bytes());
118                    *hasher.finalize().as_bytes()
119                });
120            }
121            Self::DegreeMinThenLex | Self::DegreeMaxThenLex => {
122                // Handled in algorithm inner loops where degree data is live.
123            }
124            Self::DfsPreorder | Self::BfsLevelLex | Self::EdgeKeyLex => {
125                // Structural policies handled by traversal state machines.
126            }
127        }
128    }
129}
130
131// ---------------------------------------------------------------------------
132// Complexity Witness
133// ---------------------------------------------------------------------------
134
135/// A per-execution record emitted by every CGSE-instrumented algorithm.
136///
137/// The `decision_path_blake3` field is a Merkle-style hash of every tie-break
138/// decision taken during execution: two runs on the same graph with the same
139/// policy produce identical hashes, and any ordering drift manifests as a
140/// hash mismatch.
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct ComplexityWitness {
143    /// Number of nodes in the input graph.
144    pub n: usize,
145    /// Number of edges in the input graph.
146    pub m: usize,
147    /// Dominant complexity term symbol (e.g. "n_log_n", "n_m", "n_squared").
148    pub dominant_term: String,
149    /// Observed operation count (tie-break decisions + primary loop iterations).
150    pub observed_count: u64,
151    /// Which policy governed this execution.
152    pub policy: TieBreakPolicy,
153    /// Optional RNG seed (for randomized algorithms).
154    pub seed: Option<u64>,
155    /// Blake3 hash over the ordered sequence of tie-break decisions.
156    pub decision_path_blake3: [u8; 32],
157}
158
159// ---------------------------------------------------------------------------
160// Witness Sink — the per-execution collector
161// ---------------------------------------------------------------------------
162
163/// Collects tie-break decisions during a single algorithm execution.
164///
165/// Algorithms call [`WitnessSink::record_decision`] on every tie-break;
166/// the sink updates a streaming Blake3 hasher and increments the counter.
167/// When the algorithm finishes, call [`WitnessSink::finalize`] to produce
168/// a [`ComplexityWitness`].
169pub struct WitnessSink {
170    hasher: blake3::Hasher,
171    count: u64,
172    policy: TieBreakPolicy,
173}
174
175impl WitnessSink {
176    /// Create a new sink for the given policy.
177    #[must_use]
178    pub fn new(policy: TieBreakPolicy) -> Self {
179        Self {
180            hasher: blake3::Hasher::new(),
181            count: 0,
182            policy,
183        }
184    }
185
186    /// Record a tie-break decision. The `chosen` and `rejected` labels are
187    /// hashed in order to build the decision-path fingerprint.
188    pub fn record_decision(&mut self, chosen: &str, rejected: &str) {
189        self.record_len_prefixed(chosen.as_bytes());
190        self.record_len_prefixed(rejected.as_bytes());
191        self.count += 1;
192    }
193
194    /// Record an arbitrary decision byte slice (for numeric node labels).
195    pub fn record_decision_bytes(&mut self, chosen: &[u8], rejected: &[u8]) {
196        self.record_len_prefixed(chosen);
197        self.record_len_prefixed(rejected);
198        self.count += 1;
199    }
200
201    fn record_len_prefixed(&mut self, bytes: &[u8]) {
202        let len = bytes.len() as u64;
203        self.hasher.update(&len.to_le_bytes());
204        self.hasher.update(bytes);
205    }
206
207    /// Finalize the sink into a [`ComplexityWitness`].
208    #[must_use]
209    pub fn finalize(
210        self,
211        n: usize,
212        m: usize,
213        dominant_term: &str,
214        seed: Option<u64>,
215    ) -> ComplexityWitness {
216        let hash = *self.hasher.finalize().as_bytes();
217        ComplexityWitness {
218            n,
219            m,
220            dominant_term: dominant_term.to_string(),
221            observed_count: self.count,
222            policy: self.policy,
223            seed,
224            decision_path_blake3: hash,
225        }
226    }
227}
228
229// ---------------------------------------------------------------------------
230// Witness Ledger — append-only log
231// ---------------------------------------------------------------------------
232
233/// Append-only ledger of [`ComplexityWitness`] records.
234///
235/// Serializable to JSONL for RaptorQ sidecar support. Thread-local access
236/// pattern via [`with_ledger`].
237#[derive(Debug, Default, Serialize, Deserialize)]
238pub struct WitnessLedger {
239    pub entries: Vec<ComplexityWitness>,
240}
241
242impl WitnessLedger {
243    #[must_use]
244    pub fn new() -> Self {
245        Self {
246            entries: Vec::new(),
247        }
248    }
249
250    pub fn append(&mut self, witness: ComplexityWitness) {
251        self.entries.push(witness);
252    }
253
254    /// Serialize the ledger to JSONL (one JSON object per line).
255    pub fn to_jsonl(&self) -> String {
256        let mut output = Vec::new();
257        for (index, witness) in self.entries.iter().enumerate() {
258            if index != 0 {
259                output.push(b'\n');
260            }
261            let entry_start = output.len();
262            if serde_json::to_writer(&mut output, witness).is_err() {
263                output.truncate(entry_start);
264            }
265        }
266        String::from_utf8(output).unwrap_or_default()
267    }
268
269    /// Clear all entries.
270    pub fn clear(&mut self) {
271        self.entries.clear();
272    }
273}
274
275// Thread-local ledger for automatic witness collection.
276thread_local! {
277    static THREAD_LEDGER: RefCell<WitnessLedger> = RefCell::new(WitnessLedger::new());
278}
279
280/// Execute a closure with access to the thread-local witness ledger.
281///
282/// Returns `Some(R)` on success, or `None` if the ledger is already
283/// borrowed (reentrant call). Witness collection is best-effort
284/// instrumentation, not a correctness primitive — nested calls
285/// become a no-op rather than panicking with `BorrowMutError`
286/// (br-r37-c1-g80ih).
287///
288/// br-r37-c1-03n2x: marked `#[must_use]` so silent discard of the
289/// Option without explicit ``let _ = …`` triggers an unused_must_use
290/// warning. Fire-and-forget callers must opt in explicitly to make
291/// "we don't care if this no-ops" visible at the call site.
292#[must_use = "with_ledger returns None on reentrant calls; ignore explicitly with `let _ = with_ledger(...)`"]
293pub fn with_ledger<F, R>(f: F) -> Option<R>
294where
295    F: FnOnce(&mut WitnessLedger) -> R,
296{
297    THREAD_LEDGER.with(|cell| match cell.try_borrow_mut() {
298        Ok(mut guard) => Some(f(&mut guard)),
299        Err(_) => None,
300    })
301}
302
303/// Drain and return all witnesses from the thread-local ledger.
304///
305/// Returns an empty vector if the ledger is already borrowed
306/// (reentrant call) — see [`with_ledger`] for the same defensive
307/// behaviour (br-r37-c1-g80ih).
308pub fn drain_witnesses() -> Vec<ComplexityWitness> {
309    THREAD_LEDGER.with(|cell| match cell.try_borrow_mut() {
310        Ok(mut ledger) => std::mem::take(&mut ledger.entries),
311        Err(_) => Vec::new(),
312    })
313}
314
315thread_local! {
316    static WITNESS_COLLECTION_ACTIVE: Cell<bool> = const { Cell::new(false) };
317}
318
319/// Return whether CGSE witness collection is active for the current thread.
320#[must_use]
321pub fn witness_collection_enabled() -> bool {
322    WITNESS_COLLECTION_ACTIVE.with(Cell::get)
323}
324
325struct WitnessGuard {
326    was_active: bool,
327}
328
329impl Drop for WitnessGuard {
330    fn drop(&mut self) {
331        if !self.was_active {
332            WITNESS_COLLECTION_ACTIVE.with(|cell| cell.set(false));
333        }
334    }
335}
336
337/// Run a closure with CGSE witness collection enabled and return the drained
338/// witnesses produced inside it.
339///
340/// Nested calls share the active ledger; only the outermost invocation drains
341/// the accumulated witnesses.
342pub fn collect_witnesses<F, R>(f: F) -> (R, Vec<ComplexityWitness>)
343where
344    F: FnOnce() -> R,
345{
346    let was_active = WITNESS_COLLECTION_ACTIVE.with(|cell| {
347        let active = cell.get();
348        if !active {
349            cell.set(true);
350        }
351        active
352    });
353
354    if !was_active {
355        // br-r37-c1-03n2x: explicit fire-and-forget — if a reentrant
356        // call returns None, the no-op is intentional.
357        let _ = with_ledger(WitnessLedger::clear);
358    }
359
360    let _guard = WitnessGuard { was_active };
361
362    let result = f();
363    let witnesses = if was_active {
364        Vec::new()
365    } else {
366        drain_witnesses()
367    };
368
369    (result, witnesses)
370}
371
372// ---------------------------------------------------------------------------
373// Algorithm family registry
374// ---------------------------------------------------------------------------
375
376/// Declares the canonical tie-break policy for an algorithm family.
377///
378/// This is used by conformance tests to verify that each algorithm's
379/// declared policy matches its observed ordering behavior.
380#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct AlgorithmFamilyPolicy {
382    pub family: String,
383    pub algorithm: String,
384    pub policy: TieBreakPolicy,
385    pub dominant_complexity: String,
386}
387
388/// The V1 reference algorithms whose call sites are wired through CGSE.
389#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
390#[serde(rename_all = "snake_case")]
391pub enum ReferenceAlgorithm {
392    Dijkstra,
393    BellmanFord,
394    Bfs,
395    Dfs,
396    MaxWeightMatching,
397    MinWeightMatching,
398    ConnectedComponents,
399    StronglyConnectedComponents,
400    Kruskal,
401    Prim,
402    EulerianCircuit,
403    TopologicalSort,
404}
405
406impl ReferenceAlgorithm {
407    pub const ALL: [Self; 12] = [
408        Self::Dijkstra,
409        Self::BellmanFord,
410        Self::Bfs,
411        Self::Dfs,
412        Self::MaxWeightMatching,
413        Self::MinWeightMatching,
414        Self::ConnectedComponents,
415        Self::StronglyConnectedComponents,
416        Self::Kruskal,
417        Self::Prim,
418        Self::EulerianCircuit,
419        Self::TopologicalSort,
420    ];
421
422    #[must_use]
423    pub const fn family(self) -> &'static str {
424        match self {
425            Self::Dijkstra | Self::BellmanFord => "shortest_path",
426            Self::Bfs | Self::Dfs => "traversal",
427            Self::MaxWeightMatching | Self::MinWeightMatching => "matching",
428            Self::ConnectedComponents | Self::StronglyConnectedComponents => "connectivity",
429            Self::Kruskal | Self::Prim => "trees",
430            Self::EulerianCircuit => "euler",
431            Self::TopologicalSort => "dag",
432        }
433    }
434
435    #[must_use]
436    pub const fn algorithm(self) -> &'static str {
437        match self {
438            Self::Dijkstra => "dijkstra",
439            Self::BellmanFord => "bellman_ford",
440            Self::Bfs => "bfs",
441            Self::Dfs => "dfs",
442            Self::MaxWeightMatching => "max_weight_matching",
443            Self::MinWeightMatching => "min_weight_matching",
444            Self::ConnectedComponents => "connected_components",
445            Self::StronglyConnectedComponents => "strongly_connected_components",
446            Self::Kruskal => "kruskal",
447            Self::Prim => "prim",
448            Self::EulerianCircuit => "eulerian_circuit",
449            Self::TopologicalSort => "topological_sort",
450        }
451    }
452
453    #[must_use]
454    pub const fn policy(self) -> TieBreakPolicy {
455        match self {
456            // Dijkstra's heap uses DijkstraState { dist, seq, node } where
457            // `seq` is a monotonic per-push counter. Ties on `dist` resolve
458            // FIFO, matching NetworkX's heapq + itertools.count() exactly.
459            Self::Dijkstra => TieBreakPolicy::WeightThenInsertionOrder,
460            Self::BellmanFord => TieBreakPolicy::InsertionOrder,
461            Self::Bfs => TieBreakPolicy::InsertionOrder,
462            Self::Dfs => TieBreakPolicy::InsertionOrder,
463            Self::MaxWeightMatching => TieBreakPolicy::WeightThenLex,
464            Self::MinWeightMatching => TieBreakPolicy::WeightThenLex,
465            Self::ConnectedComponents => TieBreakPolicy::LexMin,
466            Self::StronglyConnectedComponents => TieBreakPolicy::InsertionOrder,
467            // Kruskal sorts edges by (weight, left, right) lex-min; pure
468            // WeightThenLex with no insertion-counter component.
469            Self::Kruskal => TieBreakPolicy::WeightThenLex,
470            // Prim's PrimEdgeState also threads `seq` as the *quaternary*
471            // key after (weight, left, right); for the V1 registry's
472            // single-policy slot, WeightThenLex captures the dominant
473            // behavior (seq matters only when weight, left, right all tie,
474            // which is unusual outside multigraphs).
475            Self::Prim => TieBreakPolicy::WeightThenLex,
476            Self::EulerianCircuit => TieBreakPolicy::InsertionOrder,
477            Self::TopologicalSort => TieBreakPolicy::InsertionOrder,
478        }
479    }
480
481    #[must_use]
482    pub const fn dominant_complexity(self) -> &'static str {
483        match self {
484            Self::Dijkstra => "n_plus_m_log_n",
485            Self::BellmanFord => "n_m",
486            Self::Bfs => "n_plus_m",
487            Self::Dfs => "n_plus_m",
488            Self::MaxWeightMatching | Self::MinWeightMatching => "n_m_alpha",
489            Self::ConnectedComponents => "n_plus_m",
490            Self::StronglyConnectedComponents => "n_plus_m",
491            Self::Kruskal => "m_log_m",
492            Self::Prim => "m_log_n",
493            Self::EulerianCircuit => "m",
494            Self::TopologicalSort => "n_plus_m",
495        }
496    }
497
498    #[must_use]
499    pub fn policy_row(self) -> AlgorithmFamilyPolicy {
500        AlgorithmFamilyPolicy {
501            family: self.family().to_owned(),
502            algorithm: self.algorithm().to_owned(),
503            policy: self.policy(),
504            dominant_complexity: self.dominant_complexity().to_owned(),
505        }
506    }
507
508    #[must_use]
509    pub fn from_algorithm_id(algorithm: &str) -> Option<Self> {
510        Some(match algorithm {
511            "dijkstra" => Self::Dijkstra,
512            "bellman_ford" => Self::BellmanFord,
513            "bfs" => Self::Bfs,
514            "dfs" => Self::Dfs,
515            "max_weight_matching" => Self::MaxWeightMatching,
516            "min_weight_matching" => Self::MinWeightMatching,
517            "connected_components" => Self::ConnectedComponents,
518            "strongly_connected_components" => Self::StronglyConnectedComponents,
519            "kruskal" => Self::Kruskal,
520            "prim" => Self::Prim,
521            "eulerian_circuit" => Self::EulerianCircuit,
522            "topological_sort" => Self::TopologicalSort,
523            _ => return None,
524        })
525    }
526}
527
528/// The canonical policy registry for V1 algorithms.
529///
530/// Each entry maps an algorithm to its declared tie-break policy and
531/// dominant complexity term.
532#[must_use]
533pub fn v1_policy_registry() -> Vec<AlgorithmFamilyPolicy> {
534    ReferenceAlgorithm::ALL
535        .into_iter()
536        .map(ReferenceAlgorithm::policy_row)
537        .collect()
538}
539
540// ---------------------------------------------------------------------------
541// Complexity Oracle — C8
542// ---------------------------------------------------------------------------
543
544/// Result of complexity bound verification.
545#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
546pub struct ComplexityBoundResult {
547    pub within_bounds: bool,
548    pub observed_count: u64,
549    pub upper_bound: u64,
550    pub dominant_term: String,
551    pub formula: String,
552}
553
554/// Evaluate the analytic upper bound for a complexity class.
555pub fn analytic_upper_bound(dominant_term: &str, n: usize, m: usize) -> Option<u64> {
556    let n = n as u64;
557    let m = m as u64;
558    match dominant_term {
559        "n" => Some(n),
560        "m" => Some(m),
561        "n_plus_m" => Some(n.saturating_add(m)),
562        "n_log_n" => {
563            let log_n = if n <= 1 {
564                1
565            } else {
566                (n as f64).log2().ceil() as u64
567            };
568            Some(n.saturating_mul(log_n))
569        }
570        "n_plus_m_log_n" => {
571            let log_n = if n <= 1 {
572                1
573            } else {
574                (n as f64).log2().ceil() as u64
575            };
576            Some(n.saturating_add(m.saturating_mul(log_n)))
577        }
578        "n_m" => Some(n.saturating_mul(m)),
579        "n_squared" => Some(n.saturating_mul(n)),
580        "n_m_alpha" => {
581            // n * m * inverse_ackermann(n), approximated as n * m * log*(n)
582            let alpha = if n <= 1 {
583                1
584            } else {
585                ((n as f64).log2().log2().ceil() as u64).max(1)
586            };
587            Some(n.saturating_mul(m).saturating_mul(alpha))
588        }
589        "m_log_m" => {
590            let log_m = if m <= 1 {
591                1
592            } else {
593                (m as f64).log2().ceil() as u64
594            };
595            Some(m.saturating_mul(log_m))
596        }
597        "m_log_n" => {
598            let log_n = if n <= 1 {
599                1
600            } else {
601                (n as f64).log2().ceil() as u64
602            };
603            Some(m.saturating_mul(log_n))
604        }
605        _ => None,
606    }
607}
608
609/// Verify that a witness's observed count is within the expected complexity bounds.
610/// Returns a result indicating whether the count is acceptable.
611pub fn verify_complexity_bound(witness: &ComplexityWitness) -> Option<ComplexityBoundResult> {
612    let upper = analytic_upper_bound(&witness.dominant_term, witness.n, witness.m)?;
613    // Allow a 2x multiplier for constant factors
614    let adjusted_upper = upper.saturating_mul(2);
615    Some(ComplexityBoundResult {
616        within_bounds: witness.observed_count <= adjusted_upper,
617        observed_count: witness.observed_count,
618        upper_bound: adjusted_upper,
619        dominant_term: witness.dominant_term.clone(),
620        formula: format!("2 * f({}, {})", witness.n, witness.m),
621    })
622}
623
624/// Complexity oracle assertion: panic if witness exceeds expected bounds.
625pub fn assert_complexity_within_bounds(witness: &ComplexityWitness) {
626    if let Some(result) = verify_complexity_bound(witness) {
627        assert!(
628            result.within_bounds,
629            "Complexity bound violation: observed {} operations, expected at most {} for {} complexity (n={}, m={})",
630            result.observed_count, result.upper_bound, result.dominant_term, witness.n, witness.m
631        );
632    }
633}
634
635// ---------------------------------------------------------------------------
636// Counter-Example Mining — G3
637// ---------------------------------------------------------------------------
638
639/// A counter-example candidate found during witness-hash mining.
640///
641/// When two executions of the same algorithm on the same graph produce
642/// different witness hashes, we have found a potential non-determinism bug.
643#[derive(Debug, Clone, Serialize, Deserialize)]
644pub struct CounterExample {
645    /// The algorithm that exhibited non-determinism.
646    pub algorithm: ReferenceAlgorithm,
647    /// The graph that triggered the issue (serialized as edge list).
648    pub graph_edges: Vec<(String, String)>,
649    /// Number of nodes in the graph.
650    pub node_count: usize,
651    /// Whether the graph is directed.
652    pub directed: bool,
653    /// Witness from the first run.
654    pub witness_a: ComplexityWitness,
655    /// Witness from the second run.
656    pub witness_b: ComplexityWitness,
657    /// Description of the discrepancy.
658    pub discrepancy: String,
659}
660
661/// Result of a counter-example mining session.
662#[derive(Debug, Clone, Serialize, Deserialize)]
663pub struct MiningResult {
664    /// Counter-examples found during mining.
665    pub counter_examples: Vec<CounterExample>,
666    /// Number of graphs tested.
667    pub graphs_tested: u64,
668    /// Number of algorithm executions.
669    pub executions: u64,
670    /// Algorithms that passed all tests.
671    pub passing_algorithms: Vec<ReferenceAlgorithm>,
672}
673
674impl MiningResult {
675    /// Returns true if no counter-examples were found.
676    #[must_use]
677    pub fn is_clean(&self) -> bool {
678        self.counter_examples.is_empty()
679    }
680}
681
682/// Configuration for counter-example mining.
683#[derive(Debug, Clone)]
684pub struct MiningConfig {
685    /// Number of random graphs to generate per algorithm.
686    pub graphs_per_algorithm: usize,
687    /// Number of repeated executions per graph (to detect non-determinism).
688    pub executions_per_graph: usize,
689    /// Maximum number of nodes in generated graphs.
690    pub max_nodes: usize,
691    /// Maximum edge density (0.0 to 1.0).
692    pub max_density: f64,
693    /// Seed for reproducible random generation.
694    pub seed: u64,
695}
696
697impl Default for MiningConfig {
698    fn default() -> Self {
699        Self {
700            graphs_per_algorithm: 100,
701            executions_per_graph: 3,
702            max_nodes: 20,
703            max_density: 0.5,
704            seed: 0,
705        }
706    }
707}
708
709/// Compares two witnesses for consistency.
710///
711/// Returns a description of the discrepancy if the witnesses differ in
712/// decision path (the main indicator of non-determinism).
713#[must_use]
714pub fn compare_witnesses(a: &ComplexityWitness, b: &ComplexityWitness) -> Option<String> {
715    if a.decision_path_blake3 != b.decision_path_blake3 {
716        let hash_a = hex::encode(&a.decision_path_blake3[..8]);
717        let hash_b = hex::encode(&b.decision_path_blake3[..8]);
718        return Some(format!(
719            "Decision path hash mismatch: {}... vs {}... (counts: {} vs {})",
720            hash_a, hash_b, a.observed_count, b.observed_count
721        ));
722    }
723
724    if a.observed_count != b.observed_count {
725        return Some(format!(
726            "Operation count mismatch: {} vs {} (hashes match)",
727            a.observed_count, b.observed_count
728        ));
729    }
730
731    None
732}
733
734/// Verifies witness determinism by running the same algorithm multiple times
735/// and checking for hash consistency.
736///
737/// Returns `None` if all runs produce identical witnesses, or a `CounterExample`
738/// if non-determinism is detected.
739pub fn verify_witness_determinism<F>(
740    algorithm: ReferenceAlgorithm,
741    graph_edges: Vec<(String, String)>,
742    node_count: usize,
743    directed: bool,
744    iterations: usize,
745    mut run_algorithm: F,
746) -> Option<CounterExample>
747where
748    F: FnMut() -> Vec<ComplexityWitness>,
749{
750    if iterations < 2 {
751        return None;
752    }
753
754    let first_witnesses = run_algorithm();
755    let first_witness = first_witnesses.into_iter().next()?;
756
757    for _ in 1..iterations {
758        let witnesses = run_algorithm();
759        if let Some(witness) = witnesses.into_iter().next()
760            && let Some(discrepancy) = compare_witnesses(&first_witness, &witness)
761        {
762            return Some(CounterExample {
763                algorithm,
764                graph_edges,
765                node_count,
766                directed,
767                witness_a: first_witness,
768                witness_b: witness,
769                discrepancy,
770            });
771        }
772    }
773
774    None
775}
776
777/// Generates a deterministic pseudo-random value from a seed and index.
778#[must_use]
779fn seeded_random(seed: u64, index: u64) -> u64 {
780    // Simple LCG for reproducibility
781    let mut state = seed.wrapping_add(index);
782    state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
783    state
784}
785
786/// Generates random graph edges for testing.
787#[must_use]
788pub fn generate_random_edges(
789    node_count: usize,
790    density: f64,
791    directed: bool,
792    seed: u64,
793) -> Vec<(String, String)> {
794    let mut edges = Vec::new();
795    let mut idx = 0u64;
796
797    for i in 0..node_count {
798        let j_start = if directed { 0 } else { i + 1 };
799        for j in j_start..node_count {
800            if i == j {
801                continue;
802            }
803            let rand = (seeded_random(seed, idx) % 1000) as f64 / 1000.0;
804            idx += 1;
805            if rand < density {
806                edges.push((i.to_string(), j.to_string()));
807            }
808        }
809    }
810
811    edges
812}
813
814/// Serializes a mining result to JSONL format.
815#[must_use]
816pub fn mining_result_to_jsonl(result: &MiningResult) -> String {
817    let mut lines = Vec::new();
818
819    // Header line
820    lines.push(
821        serde_json::json!({
822            "type": "mining_summary",
823            "graphs_tested": result.graphs_tested,
824            "executions": result.executions,
825            "counter_examples_found": result.counter_examples.len(),
826            "passing_algorithms": result.passing_algorithms.iter()
827                .map(|a| a.algorithm())
828                .collect::<Vec<_>>(),
829        })
830        .to_string(),
831    );
832
833    // Counter-example lines
834    for ce in &result.counter_examples {
835        lines.push(serde_json::to_string(ce).unwrap_or_default());
836    }
837
838    lines.join("\n")
839}
840
841// ---------------------------------------------------------------------------
842// Tests
843// ---------------------------------------------------------------------------
844
845#[cfg(test)]
846mod tests {
847    use super::*;
848    use std::hint::black_box;
849    use std::time::Instant;
850
851    #[test]
852    fn test_policy_id_stable() {
853        assert_eq!(TieBreakPolicy::LexMin.id(), "lex_min");
854        assert_eq!(TieBreakPolicy::WeightThenLex.id(), "weight_then_lex");
855        assert_eq!(
856            TieBreakPolicy::DeterministicHash { seed: 42 }.id(),
857            "deterministic_hash"
858        );
859    }
860
861    #[test]
862    fn test_policy_roundtrip_serde() {
863        for policy in [
864            TieBreakPolicy::LexMin,
865            TieBreakPolicy::LexMax,
866            TieBreakPolicy::InsertionOrder,
867            TieBreakPolicy::ReverseInsertionOrder,
868            TieBreakPolicy::WeightThenLex,
869            TieBreakPolicy::LexThenWeight,
870            TieBreakPolicy::DeterministicHash { seed: 42 },
871            TieBreakPolicy::DegreeMinThenLex,
872            TieBreakPolicy::DegreeMaxThenLex,
873            TieBreakPolicy::DfsPreorder,
874            TieBreakPolicy::BfsLevelLex,
875            TieBreakPolicy::EdgeKeyLex,
876        ] {
877            let json = serde_json::to_string(&policy).unwrap();
878            let back: TieBreakPolicy = serde_json::from_str(&json).unwrap();
879            assert_eq!(policy, back);
880        }
881    }
882
883    #[test]
884    fn test_witness_sink_deterministic_hash() {
885        let mut sink1 = WitnessSink::new(TieBreakPolicy::LexMin);
886        sink1.record_decision("a", "b");
887        sink1.record_decision("c", "d");
888        let w1 = sink1.finalize(4, 3, "n_plus_m", None);
889
890        let mut sink2 = WitnessSink::new(TieBreakPolicy::LexMin);
891        sink2.record_decision("a", "b");
892        sink2.record_decision("c", "d");
893        let w2 = sink2.finalize(4, 3, "n_plus_m", None);
894
895        // Same decisions → same hash.
896        assert_eq!(w1.decision_path_blake3, w2.decision_path_blake3);
897        assert_eq!(w1.observed_count, 2);
898    }
899
900    #[test]
901    fn test_witness_sink_different_decisions_different_hash() {
902        let mut sink1 = WitnessSink::new(TieBreakPolicy::LexMin);
903        sink1.record_decision("a", "b");
904        let w1 = sink1.finalize(2, 1, "n", None);
905
906        let mut sink2 = WitnessSink::new(TieBreakPolicy::LexMin);
907        sink2.record_decision("b", "a"); // reversed
908        let w2 = sink2.finalize(2, 1, "n", None);
909
910        // Different decisions → different hash.
911        assert_ne!(w1.decision_path_blake3, w2.decision_path_blake3);
912    }
913
914    #[test]
915    fn test_witness_ledger_jsonl() {
916        let mut ledger = WitnessLedger::new();
917        let mut sink = WitnessSink::new(TieBreakPolicy::InsertionOrder);
918        sink.record_decision("x", "y");
919        ledger.append(sink.finalize(3, 2, "n_plus_m", None));
920
921        let jsonl = ledger.to_jsonl();
922        assert!(!jsonl.is_empty());
923        let _: ComplexityWitness = serde_json::from_str(&jsonl).unwrap();
924    }
925
926    #[test]
927    #[ignore = "same-binary release-profile performance probe"]
928    fn witness_ledger_streaming_jsonl_ab() {
929        fn frozen_jsonl(ledger: &WitnessLedger) -> String {
930            ledger
931                .entries
932                .iter()
933                .map(|witness| serde_json::to_string(witness).unwrap_or_default())
934                .collect::<Vec<_>>()
935                .join("\n")
936        }
937
938        let policies = [
939            TieBreakPolicy::LexMin,
940            TieBreakPolicy::InsertionOrder,
941            TieBreakPolicy::WeightThenInsertionOrder,
942            TieBreakPolicy::DeterministicHash { seed: 0x5eed },
943            TieBreakPolicy::BfsLevelLex,
944        ];
945        let entry_count = 2_048usize;
946        let ledger = WitnessLedger {
947            entries: (0..entry_count)
948                .map(|index| ComplexityWitness {
949                    n: index + 1,
950                    m: index * 3 + 7,
951                    dominant_term: match index % 3 {
952                        0 => "n_plus_m",
953                        1 => "n_log_n",
954                        _ => "m_log_m",
955                    }
956                    .to_owned(),
957                    observed_count: (index as u64 + 11) * 17,
958                    policy: policies[index % policies.len()],
959                    seed: (index % 4 == 0).then_some(index as u64),
960                    decision_path_blake3: [(index * 29) as u8; 32],
961                })
962                .collect(),
963        };
964
965        assert_eq!(
966            WitnessLedger::new().to_jsonl(),
967            frozen_jsonl(&WitnessLedger::new())
968        );
969        assert_eq!(
970            ledger.to_jsonl(),
971            frozen_jsonl(&ledger),
972            "JSONL bytes drifted"
973        );
974
975        let rounds = 15usize;
976        let time = |streaming: bool| {
977            let started = Instant::now();
978            let output = if streaming {
979                ledger.to_jsonl()
980            } else {
981                frozen_jsonl(&ledger)
982            };
983            let elapsed = started.elapsed().as_nanos();
984            black_box(output);
985            elapsed
986        };
987        for _ in 0..3 {
988            black_box(time(false));
989            black_box(time(true));
990        }
991
992        let paired = |candidate: bool, baseline: bool| {
993            let mut baseline_ns = Vec::with_capacity(rounds);
994            let mut candidate_ns = Vec::with_capacity(rounds);
995            for round in 0..rounds {
996                let (base, cand) = if round % 2 == 0 {
997                    (time(baseline), time(candidate))
998                } else {
999                    let cand = time(candidate);
1000                    let base = time(baseline);
1001                    (base, cand)
1002                };
1003                baseline_ns.push(base);
1004                candidate_ns.push(cand);
1005            }
1006            (baseline_ns, candidate_ns)
1007        };
1008        let median = |samples: &[u128]| {
1009            let mut sorted = samples.to_vec();
1010            sorted.sort_unstable();
1011            sorted[sorted.len() / 2]
1012        };
1013        let report = |name: &str, baseline_ns: &[u128], candidate_ns: &[u128]| {
1014            let baseline_median = median(baseline_ns);
1015            let candidate_median = median(candidate_ns);
1016            let wins = baseline_ns
1017                .iter()
1018                .zip(candidate_ns)
1019                .filter(|(baseline, candidate)| baseline > candidate)
1020                .count();
1021            println!(
1022                "CGSE_WITNESS_JSONL_AB {name}: entries={entry_count} \
1023                 baseline_median_ns={baseline_median} candidate_median_ns={candidate_median} \
1024                 ratio={:.4}x wins={wins}/{rounds} exact_bytes=true",
1025                baseline_median as f64 / candidate_median as f64,
1026            );
1027        };
1028
1029        let (baseline_ns, candidate_ns) = paired(true, false);
1030        report("string_vec_join_vs_streaming", &baseline_ns, &candidate_ns);
1031        let (null_a_ns, null_b_ns) = paired(true, true);
1032        report("streaming_vs_streaming_null", &null_a_ns, &null_b_ns);
1033    }
1034
1035    #[test]
1036    fn test_thread_local_ledger() {
1037        let _ = with_ledger(|l| l.clear());
1038
1039        let mut sink = WitnessSink::new(TieBreakPolicy::BfsLevelLex);
1040        sink.record_decision("1", "2");
1041        let w = sink.finalize(5, 4, "n_plus_m", None);
1042        let _ = with_ledger(|l| l.append(w));
1043
1044        let drained = drain_witnesses();
1045        assert_eq!(drained.len(), 1);
1046        assert_eq!(drained[0].policy, TieBreakPolicy::BfsLevelLex);
1047    }
1048
1049    #[test]
1050    fn test_v1_registry_has_12_entries() {
1051        let reg = v1_policy_registry();
1052        assert_eq!(reg.len(), 12);
1053    }
1054
1055    #[test]
1056    fn test_v1_registry_unique_algorithms() {
1057        let reg = v1_policy_registry();
1058        let mut algos: Vec<&str> = reg.iter().map(|r| r.algorithm.as_str()).collect();
1059        algos.sort();
1060        algos.dedup();
1061        assert_eq!(algos.len(), 12, "All 12 algorithms should be unique");
1062    }
1063
1064    #[test]
1065    fn test_analytic_upper_bound_n_plus_m() {
1066        assert_eq!(analytic_upper_bound("n_plus_m", 100, 200), Some(300));
1067    }
1068
1069    #[test]
1070    fn test_analytic_upper_bound_n_log_n() {
1071        let bound = analytic_upper_bound("n_log_n", 1024, 0).unwrap();
1072        // 1024 * ceil(log2(1024)) = 1024 * 10 = 10240
1073        assert_eq!(bound, 10240);
1074    }
1075
1076    #[test]
1077    fn test_analytic_upper_bound_m_log_m() {
1078        let bound = analytic_upper_bound("m_log_m", 0, 256).unwrap();
1079        // 256 * ceil(log2(256)) = 256 * 8 = 2048
1080        assert_eq!(bound, 2048);
1081    }
1082
1083    #[test]
1084    fn test_verify_complexity_bound_within() {
1085        let witness = ComplexityWitness {
1086            n: 100,
1087            m: 200,
1088            dominant_term: "n_plus_m".to_owned(),
1089            observed_count: 500, // Well within 2 * 300 = 600
1090            policy: TieBreakPolicy::LexMin,
1091            seed: None,
1092            decision_path_blake3: [0u8; 32],
1093        };
1094        let result = verify_complexity_bound(&witness).unwrap();
1095        assert!(result.within_bounds);
1096        assert_eq!(result.upper_bound, 600);
1097    }
1098
1099    #[test]
1100    fn test_verify_complexity_bound_exceeded() {
1101        let witness = ComplexityWitness {
1102            n: 100,
1103            m: 200,
1104            dominant_term: "n_plus_m".to_owned(),
1105            observed_count: 1000, // Exceeds 2 * 300 = 600
1106            policy: TieBreakPolicy::LexMin,
1107            seed: None,
1108            decision_path_blake3: [0u8; 32],
1109        };
1110        let result = verify_complexity_bound(&witness).unwrap();
1111        assert!(!result.within_bounds);
1112    }
1113
1114    #[test]
1115    fn test_analytic_upper_bound_unknown_term() {
1116        assert_eq!(analytic_upper_bound("unknown", 100, 200), None);
1117    }
1118
1119    #[test]
1120    fn test_collect_witnesses_panic_safety() {
1121        let result = std::panic::catch_unwind(|| {
1122            collect_witnesses(|| {
1123                std::panic::resume_unwind(Box::new("intentional panic"));
1124            });
1125        });
1126        assert!(result.is_err());
1127        // Verify that the thread-local state was reset correctly despite the panic.
1128        assert!(!witness_collection_enabled());
1129    }
1130
1131    // -------------------------------------------------------------------------
1132    // Counter-Example Mining Tests
1133    // -------------------------------------------------------------------------
1134
1135    #[test]
1136    fn test_compare_witnesses_identical() {
1137        let w1 = ComplexityWitness {
1138            n: 10,
1139            m: 15,
1140            dominant_term: "n_plus_m".to_owned(),
1141            observed_count: 25,
1142            policy: TieBreakPolicy::LexMin,
1143            seed: None,
1144            decision_path_blake3: [42u8; 32],
1145        };
1146        let w2 = w1.clone();
1147        assert!(compare_witnesses(&w1, &w2).is_none());
1148    }
1149
1150    #[test]
1151    fn test_compare_witnesses_hash_mismatch() {
1152        let w1 = ComplexityWitness {
1153            n: 10,
1154            m: 15,
1155            dominant_term: "n_plus_m".to_owned(),
1156            observed_count: 25,
1157            policy: TieBreakPolicy::LexMin,
1158            seed: None,
1159            decision_path_blake3: [42u8; 32],
1160        };
1161        let w2 = ComplexityWitness {
1162            decision_path_blake3: [99u8; 32],
1163            ..w1.clone()
1164        };
1165        let result = compare_witnesses(&w1, &w2);
1166        assert!(result.is_some());
1167        assert!(result.unwrap().contains("hash mismatch"));
1168    }
1169
1170    #[test]
1171    fn test_compare_witnesses_count_mismatch() {
1172        let w1 = ComplexityWitness {
1173            n: 10,
1174            m: 15,
1175            dominant_term: "n_plus_m".to_owned(),
1176            observed_count: 25,
1177            policy: TieBreakPolicy::LexMin,
1178            seed: None,
1179            decision_path_blake3: [42u8; 32],
1180        };
1181        let w2 = ComplexityWitness {
1182            observed_count: 30,
1183            ..w1.clone()
1184        };
1185        let result = compare_witnesses(&w1, &w2);
1186        assert!(result.is_some());
1187        assert!(result.unwrap().contains("count mismatch"));
1188    }
1189
1190    #[test]
1191    fn test_generate_random_edges_deterministic() {
1192        let edges1 = generate_random_edges(5, 0.5, false, 12345);
1193        let edges2 = generate_random_edges(5, 0.5, false, 12345);
1194        assert_eq!(edges1, edges2);
1195    }
1196
1197    #[test]
1198    fn test_generate_random_edges_different_seeds() {
1199        let edges1 = generate_random_edges(5, 0.5, false, 12345);
1200        let edges2 = generate_random_edges(5, 0.5, false, 54321);
1201        assert_ne!(edges1, edges2);
1202    }
1203
1204    #[test]
1205    fn test_generate_random_edges_directed() {
1206        let edges = generate_random_edges(3, 1.0, true, 0);
1207        // Directed graph with n=3 can have up to 6 edges (3*2)
1208        // but some may be filtered by the random process
1209        assert!(!edges.is_empty());
1210    }
1211
1212    #[test]
1213    fn test_mining_result_clean() {
1214        let result = MiningResult {
1215            counter_examples: vec![],
1216            graphs_tested: 100,
1217            executions: 300,
1218            passing_algorithms: ReferenceAlgorithm::ALL.to_vec(),
1219        };
1220        assert!(result.is_clean());
1221    }
1222
1223    #[test]
1224    fn test_mining_result_to_jsonl_format() {
1225        let result = MiningResult {
1226            counter_examples: vec![],
1227            graphs_tested: 10,
1228            executions: 30,
1229            passing_algorithms: vec![ReferenceAlgorithm::Bfs, ReferenceAlgorithm::Dfs],
1230        };
1231        let jsonl = mining_result_to_jsonl(&result);
1232        assert!(jsonl.contains("mining_summary"));
1233        assert!(jsonl.contains("graphs_tested"));
1234        assert!(jsonl.contains("10"));
1235    }
1236
1237    #[test]
1238    fn test_verify_witness_determinism_consistent() {
1239        let edges = vec![("0".to_string(), "1".to_string())];
1240        let mut call_count = 0;
1241
1242        let result =
1243            verify_witness_determinism(ReferenceAlgorithm::Bfs, edges, 2, false, 3, || {
1244                call_count += 1;
1245                vec![ComplexityWitness {
1246                    n: 2,
1247                    m: 1,
1248                    dominant_term: "n_plus_m".to_owned(),
1249                    observed_count: 3,
1250                    policy: TieBreakPolicy::InsertionOrder,
1251                    seed: None,
1252                    decision_path_blake3: [1u8; 32],
1253                }]
1254            });
1255
1256        assert!(
1257            result.is_none(),
1258            "Consistent witnesses should not produce counter-example"
1259        );
1260        assert_eq!(call_count, 3);
1261    }
1262
1263    #[test]
1264    fn test_verify_witness_determinism_inconsistent() {
1265        let edges = vec![("0".to_string(), "1".to_string())];
1266        let mut call_count = 0;
1267
1268        let result =
1269            verify_witness_determinism(ReferenceAlgorithm::Bfs, edges, 2, false, 3, || {
1270                call_count += 1;
1271                vec![ComplexityWitness {
1272                    n: 2,
1273                    m: 1,
1274                    dominant_term: "n_plus_m".to_owned(),
1275                    observed_count: 3,
1276                    policy: TieBreakPolicy::InsertionOrder,
1277                    seed: None,
1278                    // Different hash each call to simulate non-determinism
1279                    decision_path_blake3: [call_count as u8; 32],
1280                }]
1281            });
1282
1283        assert!(
1284            result.is_some(),
1285            "Inconsistent witnesses should produce counter-example"
1286        );
1287        let ce = result.unwrap();
1288        assert_eq!(ce.algorithm, ReferenceAlgorithm::Bfs);
1289        assert!(ce.discrepancy.contains("hash mismatch"));
1290    }
1291
1292    #[test]
1293    fn test_mining_config_default() {
1294        let config = MiningConfig::default();
1295        assert_eq!(config.graphs_per_algorithm, 100);
1296        assert_eq!(config.executions_per_graph, 3);
1297        assert_eq!(config.max_nodes, 20);
1298        assert!(config.max_density > 0.0);
1299    }
1300}