yantrikdb 0.7.1

Cognitive memory engine for persistent AI systems
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
//! RFC 008 M5b: Cognitive moves — the spine of reasoning.
//!
//! Per M5a locked spec (Saga note 19): this module owns the move substrate.
//! Moves are append-only events recording reasoning transformations.
//! Inputs/outputs/side-effects flow through normalized edge tables.
//! Corrections are first-class events; originals are never mutated for
//! semantic correction (posthoc outcome enrichment is a narrow exception).
//! Adversarial instances are staged (candidate/confirmed/rejected) with
//! governance enforced at the API layer.
//!
//! M5b is observational-first: moves record what happened; they do NOT
//! actively rewrite mobility_state. Active propagation (if ever needed)
//! is a separate feature.
//!
//! Start here when reading:
//! - `record_move_event` — create a move event + its edges atomically
//! - `record_move_outcome` — narrow posthoc mutation of outcome/yield
//! - `submit_move_correction` — correct a move's structural fields via event
//! - `list_moves_consuming_claim` / `list_moves_producing_claim` — reverse edge lookups
//! - Adversarial instance APIs: `create_adversarial_candidate`, `promote_adversarial_candidate`

use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};

use crate::error::{Result, YantrikDbError};

/// Seed vocabulary for move_type_registry. Loaded at bootstrap. Extending
/// this list is a normal operation; the registry is a soft reference, not
/// a schema CHECK.
pub const SEED_MOVE_TYPES: &[(&str, &str, Option<i64>)] = &[
    ("analogy", "Cross-domain pattern transfer from known claims to a target", Some(60_000)),
    ("decomposition", "Split a claim into case-wise sub-claims", Some(30_000)),
    ("aggregate_back", "Recombine case-wise sub-claims into an aggregate (dual of decomposition)", Some(30_000)),
    ("negate_and_test", "Generate the negation of a claim and actively seek disconfirming evidence", Some(300_000)),
    ("source_audit", "Inspect and reweight claims from a shared source (requires ψ_ancestral < 1.0)", Some(900_000)),
    ("ladder_up", "Abstract a specific claim to a more general proposition", Some(60_000)),
    ("contradiction_triage", "Structured evaluation of a Γ(c) contest signature", Some(120_000)),
    ("source_downgrade", "Reduce the weight of claims from a specific source", None),
    ("source_upgrade", "Increase the weight of claims from a specific source", None),
    ("regime_transfer", "Transport a claim/mobility state across regimes", Some(300_000)),
    ("compression", "Consolidate a source span into a compressed artifact", Some(604_800_000)),
    ("hypothesis_generation", "Escrow a candidate explanation (stress-residual minimization)", Some(2_592_000_000)),
    ("quarantine", "Flag a claim-neighborhood for isolation from downstream reasoning", None),
];

/// Seed vocabulary for inference_basis_registry. Only relevant when
/// observability = 'inferred'.
pub const SEED_INFERENCE_BASES: &[(&str, &str)] = &[
    ("structural_pattern_match", "Output claims match the structural signature of a known move type"),
    ("temporal_correlation", "Time-proximity between a trigger and observed effects suggests a move"),
    ("source_lineage_inference", "Source lineage patterns imply a move was applied"),
    ("operator_signature_match", "Operator-specific side effects (e.g. compression artifact shape) were observed"),
    ("human_annotation", "A curator declared the move retrospectively"),
];

/// Lifecycle status values for adversarial instances.
pub mod adversarial_status {
    pub const CANDIDATE: &str = "candidate";
    pub const CONFIRMED: &str = "confirmed";
    pub const REJECTED: &str = "rejected";
}

/// Observability modes for move_events.
pub mod observability {
    pub const OBSERVED: &str = "observed";
    pub const SELF_REPORTED: &str = "self_reported";
    pub const INFERRED: &str = "inferred";
}

/// Posthoc outcome labels.
pub mod posthoc_outcome {
    pub const CORROBORATED: &str = "corroborated";
    pub const RETRACTED: &str = "retracted";
    pub const HARMFUL_SIDE_EFFECT: &str = "harmful_side_effect";
}

/// A recorded cognitive move event. Append-only after insertion; only
/// posthoc_outcome / posthoc_recorded_at / yield_json may be updated
/// after the fact (narrow posthoc enrichment — everything else is
/// corrected via `submit_move_correction`, not mutation).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MoveEvent {
    pub move_id: String,
    pub move_type: String,
    pub operator_version: String,
    pub actor_id: String,
    pub context_regime: String,
    pub observability: String,
    pub inference_confidence: Option<f64>,
    pub inference_basis_json: Option<String>,
    pub dependencies_json: String,
    pub cost_tokens: Option<i64>,
    pub cost_latency_ms: Option<i64>,
    pub cost_memory_reads: Option<i64>,
    pub yield_json: String,
    pub posthoc_outcome: Option<String>,
    pub posthoc_recorded_at: Option<f64>,
    pub expected_evaluation_horizon_ms: Option<i64>,
    pub mobility_state_hash_at_move: Option<String>,
    pub contest_state_hash_at_move: Option<String>,
    pub created_at: f64,
    /// HLC bytes (16 bytes, hex-encoded in this struct for convenience).
    pub hlc_hex: String,
    pub origin_actor: String,
}

/// Input describing a claim_id referenced by a move in a specific role.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaimRef {
    pub claim_id: String,
    pub role: String,
    pub ordinal: i64,
}

/// Input describing a side-effect target with a labeled effect kind.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SideEffectRef {
    pub claim_id: String,
    pub effect_kind: String,
}

/// All fields needed to record a new move_event plus its edges atomically.
#[derive(Debug, Clone, Default)]
pub struct RecordMoveEventInput {
    pub move_type: String,
    pub operator_version: String,
    pub context_regime: Option<String>, // defaults to 'default'
    pub observability: String,
    pub inference_confidence: Option<f64>,
    pub inference_basis: Option<Vec<String>>,
    pub dependencies: Vec<String>,
    pub cost_tokens: Option<i64>,
    pub cost_latency_ms: Option<i64>,
    pub cost_memory_reads: Option<i64>,
    pub yield_json: Option<String>,
    pub expected_evaluation_horizon_ms: Option<i64>,
    pub mobility_state_hash_at_move: Option<String>,
    pub contest_state_hash_at_move: Option<String>,
    pub inputs: Vec<ClaimRef>,
    pub outputs: Vec<ClaimRef>,
    pub side_effects: Vec<SideEffectRef>,
}

/// A correction that supersedes one or more structural fields of an
/// existing move. Append-only record; downstream consumers reconstruct
/// the canonical view by joining move_events with the latest correction.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MoveCorrection {
    pub correction_id: String,
    pub original_move_id: String,
    pub corrected_move_type: Option<String>,
    pub corrected_operator_version: Option<String>,
    pub corrected_context_regime: Option<String>,
    pub correction_reason: String,
    pub corrected_by_actor_id: String,
    pub corrected_at: f64,
}

/// Adversarial instance record. Candidate → confirmed promotion is the
/// governed path; only confirmed instances may carry generalized_lesson /
/// lesson_scope_json. Rejection is terminal.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AdversarialInstance {
    pub instance_id: String,
    pub move_id: String,
    pub status: String,
    pub discovered_via: String,
    pub traced_root_cause: Option<String>,
    pub generalized_lesson: Option<String>,
    pub lesson_scope_json: Option<String>,
    pub curation_actor_id: Option<String>,
    pub discovered_at: f64,
    pub created_at: f64,
}

impl crate::engine::YantrikDB {
    // ── Bootstrap ─────────────────────────────────────────────────

    /// Seed the move_type_registry and inference_basis_registry with the
    /// canonical vocabulary. Idempotent — INSERT OR IGNORE. Safe to call
    /// multiple times at bootstrap or after migrations.
    pub fn seed_move_registries(&self) -> Result<()> {
        let conn = self.conn.lock();
        seed_registries_inner(&conn)
    }

    // ── Move event lifecycle ──────────────────────────────────────

    /// Record a new move_event and its input/output/side-effect edges
    /// atomically under a single connection lock.
    ///
    /// Soft validation:
    /// - unknown move_type logs a tracing::warn but does not reject
    /// - unknown inference_basis values log warnings but do not reject
    /// - observability rules enforced: inference_confidence and
    ///   inference_basis are valid only when observability='inferred'
    /// - expected_evaluation_horizon_ms falls back to the registry
    ///   default if not supplied
    ///
    /// Returns the new move_id (UUIDv7).
    pub fn record_move_event(&self, input: RecordMoveEventInput) -> Result<String> {
        // Observability semantic rules (enforced before anything touches SQL).
        let is_inferred = input.observability == observability::INFERRED;
        if !is_inferred {
            if input.inference_confidence.is_some() {
                return Err(YantrikDbError::InvalidInput(
                    "inference_confidence may only be set when observability='inferred'".into(),
                ));
            }
            if input
                .inference_basis
                .as_ref()
                .map(|b| !b.is_empty())
                .unwrap_or(false)
            {
                return Err(YantrikDbError::InvalidInput(
                    "inference_basis may only be non-empty when observability='inferred'".into(),
                ));
            }
        }
        if ![observability::OBSERVED, observability::SELF_REPORTED, observability::INFERRED]
            .contains(&input.observability.as_str())
        {
            return Err(YantrikDbError::InvalidInput(format!(
                "observability must be one of observed|self_reported|inferred, got '{}'",
                input.observability
            )));
        }

        let move_id = crate::id::new_id();
        let now_ts = super::now();
        let hlc = self.tick_hlc();
        let origin_actor = self.actor_id().to_string();
        let regime = input.context_regime.clone().unwrap_or_else(|| "default".to_string());

        let conn = self.conn.lock();

        // Soft-registry warnings — never reject.
        let known_type: bool = conn
            .query_row(
                "SELECT 1 FROM move_type_registry WHERE move_type = ?1",
                params![input.move_type],
                |_| Ok(true),
            )
            .unwrap_or(false);
        if !known_type {
            tracing::warn!(
                move_type = %input.move_type,
                "recording move with unregistered move_type; consider adding to move_type_registry"
            );
        }

        if is_inferred {
            if let Some(ref bases) = input.inference_basis {
                for b in bases {
                    let known: bool = conn
                        .query_row(
                            "SELECT 1 FROM inference_basis_registry WHERE basis_type = ?1",
                            params![b],
                            |_| Ok(true),
                        )
                        .unwrap_or(false);
                    if !known {
                        tracing::warn!(
                            basis_type = %b,
                            "unregistered inference_basis; consider adding to inference_basis_registry"
                        );
                    }
                }
            }
        }

        // Fall back to the registry's default horizon when not supplied
        // and only when the move_type is registered (otherwise nothing to
        // fall back to).
        let horizon = match input.expected_evaluation_horizon_ms {
            Some(h) => Some(h),
            None => conn
                .query_row(
                    "SELECT default_expected_evaluation_horizon_ms \
                     FROM move_type_registry WHERE move_type = ?1",
                    params![input.move_type],
                    |row| row.get::<_, Option<i64>>(0),
                )
                .unwrap_or(None),
        };

        let inference_basis_json = input
            .inference_basis
            .as_ref()
            .map(|b| serde_json::to_string(b).unwrap_or_else(|_| "[]".into()));
        let dependencies_json = serde_json::to_string(&input.dependencies)
            .unwrap_or_else(|_| "[]".into());
        let yield_json = input.yield_json.clone().unwrap_or_else(|| "{}".into());
        let hlc_bytes = hlc.to_bytes();

        // Perform INSERT into move_events and all edge tables inside a
        // transaction — atomic together.
        let tx = conn.unchecked_transaction()?;
        tx.execute(
            "INSERT INTO move_events (\
             move_id, move_type, operator_version, actor_id, context_regime, \
             observability, inference_confidence, inference_basis_json, dependencies_json, \
             cost_tokens, cost_latency_ms, cost_memory_reads, \
             yield_json, posthoc_outcome, posthoc_recorded_at, \
             expected_evaluation_horizon_ms, mobility_state_hash_at_move, contest_state_hash_at_move, \
             created_at, hlc, origin_actor) \
             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, NULL, NULL, \
                     ?14, ?15, ?16, ?17, ?18, ?19)",
            params![
                move_id, input.move_type, input.operator_version, origin_actor, regime,
                input.observability, input.inference_confidence, inference_basis_json, dependencies_json,
                input.cost_tokens, input.cost_latency_ms, input.cost_memory_reads,
                yield_json,
                horizon, input.mobility_state_hash_at_move, input.contest_state_hash_at_move,
                now_ts, hlc_bytes.as_slice(), self.actor_id(),
            ],
        )?;
        for ClaimRef { claim_id, role, ordinal } in &input.inputs {
            tx.execute(
                "INSERT INTO move_input_edge (move_id, claim_id, input_role, ordinal) \
                 VALUES (?1, ?2, ?3, ?4)",
                params![move_id, claim_id, role, ordinal],
            )?;
        }
        for ClaimRef { claim_id, role, ordinal } in &input.outputs {
            tx.execute(
                "INSERT INTO move_output_edge (move_id, claim_id, output_role, ordinal) \
                 VALUES (?1, ?2, ?3, ?4)",
                params![move_id, claim_id, role, ordinal],
            )?;
        }
        for SideEffectRef { claim_id, effect_kind } in &input.side_effects {
            tx.execute(
                "INSERT INTO move_side_effect_edge (move_id, claim_id, effect_kind) \
                 VALUES (?1, ?2, ?3)",
                params![move_id, claim_id, effect_kind],
            )?;
        }
        tx.commit()?;

        Ok(move_id)
    }

    /// Narrow posthoc enrichment — the only allowed mutation on a
    /// move_events row after INSERT. Sets posthoc_outcome, records the
    /// time, and optionally attaches a yield_json payload.
    ///
    /// Rejects attempts to overwrite an already-set posthoc_outcome
    /// (use `submit_move_correction` to re-assess a finalized outcome).
    pub fn record_move_outcome(
        &self,
        move_id: &str,
        outcome: &str,
        yield_json: Option<String>,
    ) -> Result<()> {
        if ![
            posthoc_outcome::CORROBORATED,
            posthoc_outcome::RETRACTED,
            posthoc_outcome::HARMFUL_SIDE_EFFECT,
        ]
        .contains(&outcome)
        {
            return Err(YantrikDbError::InvalidInput(format!(
                "posthoc_outcome must be corroborated|retracted|harmful_side_effect, got '{}'",
                outcome
            )));
        }
        let conn = self.conn.lock();
        let existing: Option<String> = conn
            .query_row(
                "SELECT posthoc_outcome FROM move_events WHERE move_id = ?1",
                params![move_id],
                |row| row.get(0),
            )
            .map_err(|e| match e {
                rusqlite::Error::QueryReturnedNoRows => {
                    YantrikDbError::NotFound(format!("move_event: {}", move_id))
                }
                other => other.into(),
            })?;
        if existing.is_some() {
            return Err(YantrikDbError::InvalidInput(format!(
                "move {} already has a posthoc_outcome; use submit_move_correction to re-assess",
                move_id
            )));
        }
        let now_ts = super::now();
        let rows = if let Some(y) = yield_json {
            conn.execute(
                "UPDATE move_events SET posthoc_outcome = ?1, posthoc_recorded_at = ?2, yield_json = ?3 \
                 WHERE move_id = ?4",
                params![outcome, now_ts, y, move_id],
            )?
        } else {
            conn.execute(
                "UPDATE move_events SET posthoc_outcome = ?1, posthoc_recorded_at = ?2 \
                 WHERE move_id = ?3",
                params![outcome, now_ts, move_id],
            )?
        };
        if rows == 0 {
            return Err(YantrikDbError::NotFound(format!("move_event: {}", move_id)));
        }

        // RFC 008 M10: auto-file adversarial candidate on negative outcomes.
        // Retractions and harmful-side-effects are signals the move was
        // epistemically or operationally wrong; record a candidate so the
        // curator or induction pipeline has a queue to work from.
        //
        // Governance rule (M5a spec): automatic systems create `candidate`
        // status with traced_root_cause only; generalized_lesson and
        // lesson_scope_json are left NULL until a curator promotes.
        if outcome == posthoc_outcome::RETRACTED || outcome == posthoc_outcome::HARMFUL_SIDE_EFFECT {
            let instance_id = crate::id::new_id();
            let discovered_via = if outcome == posthoc_outcome::RETRACTED {
                "retraction"
            } else {
                "calibration_signal"
            };
            let root_cause = format!(
                "auto-generated from posthoc_outcome='{}' on move {}",
                outcome, move_id
            );
            conn.execute(
                "INSERT INTO move_adversarial_instance (\
                 instance_id, move_id, status, discovered_via, traced_root_cause, \
                 discovered_at, created_at) \
                 VALUES (?1, ?2, 'candidate', ?3, ?4, ?5, ?5)",
                params![instance_id, move_id, discovered_via, root_cause, now_ts],
            )?;
        }
        Ok(())
    }

    /// Record a correction to a move's structural fields. Supply only
    /// the fields that changed (None = unchanged). Always succeeds as a
    /// new append-only event — never mutates the original move_events row.
    pub fn submit_move_correction(
        &self,
        original_move_id: &str,
        corrected_move_type: Option<String>,
        corrected_operator_version: Option<String>,
        corrected_context_regime: Option<String>,
        correction_reason: String,
        corrected_by_actor_id: String,
    ) -> Result<String> {
        if corrected_move_type.is_none()
            && corrected_operator_version.is_none()
            && corrected_context_regime.is_none()
        {
            return Err(YantrikDbError::InvalidInput(
                "submit_move_correction must specify at least one corrected_* field".into(),
            ));
        }
        if correction_reason.trim().is_empty() {
            return Err(YantrikDbError::InvalidInput(
                "correction_reason must not be empty".into(),
            ));
        }
        let conn = self.conn.lock();
        // Verify the original move exists (FK is also enforced at DB layer).
        let exists: bool = conn
            .query_row(
                "SELECT 1 FROM move_events WHERE move_id = ?1",
                params![original_move_id],
                |_| Ok(true),
            )
            .unwrap_or(false);
        if !exists {
            return Err(YantrikDbError::NotFound(format!(
                "original move_event: {}",
                original_move_id
            )));
        }
        let correction_id = crate::id::new_id();
        let now_ts = super::now();
        conn.execute(
            "INSERT INTO move_correction_event (\
             correction_id, original_move_id, corrected_move_type, corrected_operator_version, \
             corrected_context_regime, correction_reason, corrected_by_actor_id, corrected_at) \
             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
            params![
                correction_id, original_move_id,
                corrected_move_type, corrected_operator_version, corrected_context_regime,
                correction_reason, corrected_by_actor_id, now_ts,
            ],
        )?;
        Ok(correction_id)
    }

    // ── Read APIs ─────────────────────────────────────────────────

    /// Fetch a move_event by its move_id. Returns None if not found.
    pub fn get_move_event(&self, move_id: &str) -> Result<Option<MoveEvent>> {
        let conn = self.conn.lock();
        read_move_event(&conn, move_id)
    }

    /// All moves that consumed a given claim as an input. Ordered by
    /// created_at ASC (earliest first).
    pub fn list_moves_consuming_claim(&self, claim_id: &str, limit: usize) -> Result<Vec<MoveEvent>> {
        self.list_moves_by_edge("move_input_edge", claim_id, limit)
    }

    /// All moves that produced a given claim as an output.
    pub fn list_moves_producing_claim(&self, claim_id: &str, limit: usize) -> Result<Vec<MoveEvent>> {
        self.list_moves_by_edge("move_output_edge", claim_id, limit)
    }

    /// All moves whose side-effects touched a given claim.
    pub fn list_moves_side_effecting_claim(
        &self,
        claim_id: &str,
        limit: usize,
    ) -> Result<Vec<MoveEvent>> {
        self.list_moves_by_edge("move_side_effect_edge", claim_id, limit)
    }

    fn list_moves_by_edge(
        &self,
        edge_table: &str,
        claim_id: &str,
        limit: usize,
    ) -> Result<Vec<MoveEvent>> {
        let conn = self.conn.lock();
        let sql = format!(
            "SELECT m.* FROM move_events m \
             INNER JOIN {} e ON e.move_id = m.move_id \
             WHERE e.claim_id = ?1 \
             ORDER BY m.created_at ASC LIMIT ?2",
            edge_table
        );
        let mut stmt = conn.prepare(&sql)?;
        let rows = stmt
            .query_map(params![claim_id, limit as i64], row_to_move_event)?
            .collect::<std::result::Result<Vec<_>, _>>()?;
        Ok(rows)
    }

    /// All correction events recorded against a given original move.
    /// Ordered newest-first (latest correction dominates in canonical
    /// reconstruction).
    pub fn list_move_corrections(&self, original_move_id: &str) -> Result<Vec<MoveCorrection>> {
        let conn = self.conn.lock();
        let mut stmt = conn.prepare(
            "SELECT correction_id, original_move_id, corrected_move_type, \
             corrected_operator_version, corrected_context_regime, \
             correction_reason, corrected_by_actor_id, corrected_at \
             FROM move_correction_event WHERE original_move_id = ?1 \
             ORDER BY corrected_at DESC",
        )?;
        let rows = stmt
            .query_map(params![original_move_id], |row| {
                Ok(MoveCorrection {
                    correction_id: row.get(0)?,
                    original_move_id: row.get(1)?,
                    corrected_move_type: row.get(2)?,
                    corrected_operator_version: row.get(3)?,
                    corrected_context_regime: row.get(4)?,
                    correction_reason: row.get(5)?,
                    corrected_by_actor_id: row.get(6)?,
                    corrected_at: row.get(7)?,
                })
            })?
            .collect::<std::result::Result<Vec<_>, _>>()?;
        Ok(rows)
    }

    /// Reconstruct the canonical view of a move: base event overlaid by
    /// the latest correction (if any). Mutates the returned MoveEvent's
    /// structural fields according to the latest correction, but leaves
    /// move_id, created_at, hlc, origin_actor, and all edges untouched.
    pub fn get_move_event_canonical(&self, move_id: &str) -> Result<Option<MoveEvent>> {
        let Some(mut event) = self.get_move_event(move_id)? else {
            return Ok(None);
        };
        let corrections = self.list_move_corrections(move_id)?;
        // Latest correction wins for each field (corrections ordered newest
        // first). The first non-None value for each corrected_* field is
        // the canonical override.
        for c in &corrections {
            if let Some(ref mt) = c.corrected_move_type {
                event.move_type = mt.clone();
                break;
            }
        }
        for c in &corrections {
            if let Some(ref v) = c.corrected_operator_version {
                event.operator_version = v.clone();
                break;
            }
        }
        for c in &corrections {
            if let Some(ref r) = c.corrected_context_regime {
                event.context_regime = r.clone();
                break;
            }
        }
        Ok(Some(event))
    }

    /// Inputs of a move, in ordinal order.
    pub fn get_move_inputs(&self, move_id: &str) -> Result<Vec<ClaimRef>> {
        self.get_move_edges_generic("move_input_edge", "input_role", move_id)
    }

    /// Outputs of a move, in ordinal order.
    pub fn get_move_outputs(&self, move_id: &str) -> Result<Vec<ClaimRef>> {
        self.get_move_edges_generic("move_output_edge", "output_role", move_id)
    }

    /// Side-effects of a move.
    pub fn get_move_side_effects(&self, move_id: &str) -> Result<Vec<SideEffectRef>> {
        let conn = self.conn.lock();
        let mut stmt = conn.prepare(
            "SELECT claim_id, effect_kind FROM move_side_effect_edge \
             WHERE move_id = ?1 ORDER BY effect_kind, claim_id",
        )?;
        let rows = stmt
            .query_map(params![move_id], |row| {
                Ok(SideEffectRef {
                    claim_id: row.get(0)?,
                    effect_kind: row.get(1)?,
                })
            })?
            .collect::<std::result::Result<Vec<_>, _>>()?;
        Ok(rows)
    }

    fn get_move_edges_generic(
        &self,
        edge_table: &str,
        role_col: &str,
        move_id: &str,
    ) -> Result<Vec<ClaimRef>> {
        let conn = self.conn.lock();
        let sql = format!(
            "SELECT claim_id, {}, ordinal FROM {} WHERE move_id = ?1 ORDER BY ordinal, claim_id",
            role_col, edge_table
        );
        let mut stmt = conn.prepare(&sql)?;
        let rows = stmt
            .query_map(params![move_id], |row| {
                Ok(ClaimRef {
                    claim_id: row.get(0)?,
                    role: row.get(1)?,
                    ordinal: row.get(2)?,
                })
            })?
            .collect::<std::result::Result<Vec<_>, _>>()?;
        Ok(rows)
    }

    // ── Adversarial instance lifecycle ────────────────────────────

    /// Create an adversarial instance in `candidate` status. Automatic
    /// systems (calibration signals, retraction detectors) use this
    /// entry point. Enforces the governance rule: no generalized_lesson
    /// or lesson_scope_json may be populated on candidates.
    pub fn create_adversarial_candidate(
        &self,
        move_id: &str,
        discovered_via: &str,
        traced_root_cause: Option<String>,
    ) -> Result<String> {
        if ![
            "contradiction",
            "retraction",
            "calibration_signal",
            "human_audit",
        ]
        .contains(&discovered_via)
        {
            return Err(YantrikDbError::InvalidInput(format!(
                "discovered_via must be one of contradiction|retraction|calibration_signal|human_audit, got '{}'",
                discovered_via
            )));
        }
        let conn = self.conn.lock();
        let exists: bool = conn
            .query_row(
                "SELECT 1 FROM move_events WHERE move_id = ?1",
                params![move_id],
                |_| Ok(true),
            )
            .unwrap_or(false);
        if !exists {
            return Err(YantrikDbError::NotFound(format!(
                "move_event: {}",
                move_id
            )));
        }
        let instance_id = crate::id::new_id();
        let now_ts = super::now();
        conn.execute(
            "INSERT INTO move_adversarial_instance (\
             instance_id, move_id, status, discovered_via, traced_root_cause, \
             discovered_at, created_at) \
             VALUES (?1, ?2, 'candidate', ?3, ?4, ?5, ?5)",
            params![instance_id, move_id, discovered_via, traced_root_cause, now_ts],
        )?;
        Ok(instance_id)
    }

    /// Promote a candidate to `confirmed`, attaching the curator-validated
    /// generalized lesson and its scope. Rejects promotion of non-candidate
    /// rows and requires both lesson fields to be non-empty on confirmation.
    pub fn promote_adversarial_candidate(
        &self,
        instance_id: &str,
        generalized_lesson: String,
        lesson_scope_json: String,
        curation_actor_id: String,
    ) -> Result<()> {
        if generalized_lesson.trim().is_empty() {
            return Err(YantrikDbError::InvalidInput(
                "generalized_lesson must be non-empty when promoting to confirmed".into(),
            ));
        }
        if lesson_scope_json.trim().is_empty() {
            return Err(YantrikDbError::InvalidInput(
                "lesson_scope_json must be non-empty when promoting to confirmed".into(),
            ));
        }
        let conn = self.conn.lock();
        let status: String = conn
            .query_row(
                "SELECT status FROM move_adversarial_instance WHERE instance_id = ?1",
                params![instance_id],
                |row| row.get(0),
            )
            .map_err(|e| match e {
                rusqlite::Error::QueryReturnedNoRows => {
                    YantrikDbError::NotFound(format!("adversarial_instance: {}", instance_id))
                }
                other => other.into(),
            })?;
        if status != adversarial_status::CANDIDATE {
            return Err(YantrikDbError::InvalidInput(format!(
                "can only promote from candidate, current status is '{}'",
                status
            )));
        }
        conn.execute(
            "UPDATE move_adversarial_instance SET \
             status = 'confirmed', generalized_lesson = ?1, lesson_scope_json = ?2, \
             curation_actor_id = ?3 WHERE instance_id = ?4",
            params![generalized_lesson, lesson_scope_json, curation_actor_id, instance_id],
        )?;
        Ok(())
    }

    /// Reject an adversarial candidate — terminal state, no further
    /// transitions. Rejected instances stay in the table for audit.
    pub fn reject_adversarial_candidate(
        &self,
        instance_id: &str,
        curation_actor_id: String,
    ) -> Result<()> {
        let conn = self.conn.lock();
        let rows = conn.execute(
            "UPDATE move_adversarial_instance SET \
             status = 'rejected', curation_actor_id = ?1 \
             WHERE instance_id = ?2 AND status = 'candidate'",
            params![curation_actor_id, instance_id],
        )?;
        if rows == 0 {
            return Err(YantrikDbError::InvalidInput(format!(
                "cannot reject {}: either not found or already promoted/rejected",
                instance_id
            )));
        }
        Ok(())
    }

    /// Fetch an adversarial instance by id.
    pub fn get_adversarial_instance(
        &self,
        instance_id: &str,
    ) -> Result<Option<AdversarialInstance>> {
        let conn = self.conn.lock();
        let res = conn.query_row(
            "SELECT instance_id, move_id, status, discovered_via, traced_root_cause, \
             generalized_lesson, lesson_scope_json, curation_actor_id, discovered_at, created_at \
             FROM move_adversarial_instance WHERE instance_id = ?1",
            params![instance_id],
            row_to_adversarial,
        );
        match res {
            Ok(i) => Ok(Some(i)),
            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
            Err(e) => Err(e.into()),
        }
    }

    /// List adversarial instances for a given move.
    pub fn list_adversarial_for_move(
        &self,
        move_id: &str,
    ) -> Result<Vec<AdversarialInstance>> {
        let conn = self.conn.lock();
        let mut stmt = conn.prepare(
            "SELECT instance_id, move_id, status, discovered_via, traced_root_cause, \
             generalized_lesson, lesson_scope_json, curation_actor_id, discovered_at, created_at \
             FROM move_adversarial_instance WHERE move_id = ?1 ORDER BY discovered_at DESC",
        )?;
        let rows = stmt
            .query_map(params![move_id], row_to_adversarial)?
            .collect::<std::result::Result<Vec<_>, _>>()?;
        Ok(rows)
    }
}

// ──────────────────────────────────────────────────────────────────
// RFC 008 M8: Axiomatic composition rules.
//
// Per M5a locked spec: axioms are definitional — they live in code, not
// in the move_composition_rule table. The table stores only empirical
// and user-declared rules. Axioms are compile-checked and versioned
// with the system source; mixing them with noisy empirical observations
// would let evidence override definitional truths.
// ──────────────────────────────────────────────────────────────────

/// Axiomatic composition kinds. Each axiom declares one of these.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AxiomKind {
    /// The sequence `left ∘ right` approximates the identity at warrant
    /// level. Engine may elide such pairs from processing.
    ApproxIdentity,
    /// `left ∘ right` and `right ∘ left` are NOT equivalent; move order
    /// matters for this pair.
    NonCommutative,
    /// The `left_move_type` has a definitional precondition; applying it
    /// without the precondition holding is incoherent.
    PreconditionRequirement,
}

/// Precondition checks evaluated against mobility_state / contest_state.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Precondition {
    /// Require self_gen_ancestral strictly less than the given threshold.
    /// e.g. `SelfGenAncestralBelow(1.0)` means "there must be at least
    /// some external ancestry (not fully self-generated)."
    SelfGenAncestralBelow(f64),
    /// Require support_mass (σ) strictly above the given threshold.
    SupportMassAbove(f64),
    /// Require contest_state.heuristic_flags bitmask intersection equals zero
    /// (i.e. none of the listed flag bits are set).
    NoContestFlags(u64),
}

#[derive(Debug, Clone, Copy)]
pub struct Axiom {
    pub name: &'static str,
    pub kind: AxiomKind,
    pub left_move_type: Option<&'static str>,
    pub right_move_type: Option<&'static str>,
    pub precondition: Option<Precondition>,
    pub description: &'static str,
}

/// The axiom registry. Populated from RFC 008 § 2 and refined as new
/// definitional laws are discovered. Bumping this registry is a code
/// change, reviewed with the usual rigor; it is NOT schema migration.
pub const AXIOMS: &[Axiom] = &[
    Axiom {
        name: "decompose_aggregate_identity",
        kind: AxiomKind::ApproxIdentity,
        left_move_type: Some("decomposition"),
        right_move_type: Some("aggregate_back"),
        precondition: None,
        description: "decomposition ∘ aggregate_back ≈ identity at warrant level \
                      (case splitting followed by reaggregation preserves support structure)",
    },
    Axiom {
        name: "negate_analogize_non_commutative",
        kind: AxiomKind::NonCommutative,
        left_move_type: Some("negate_and_test"),
        right_move_type: Some("analogy"),
        precondition: None,
        description: "negating then analogizing explores different structure than \
                      analogizing then negating; the two orderings are not equivalent",
    },
    Axiom {
        name: "source_audit_requires_external_ancestry",
        kind: AxiomKind::PreconditionRequirement,
        left_move_type: Some("source_audit"),
        right_move_type: None,
        precondition: Some(Precondition::SelfGenAncestralBelow(1.0)),
        description: "source_audit is incoherent on fully self-generated claims \
                      (there is no external source to audit when ψ_ancestral = 1.0)",
    },
    Axiom {
        name: "compression_requires_support",
        kind: AxiomKind::PreconditionRequirement,
        left_move_type: Some("compression"),
        right_move_type: None,
        precondition: Some(Precondition::SupportMassAbove(0.0)),
        description: "compression over a proposition with no support mass has \
                      nothing to consolidate; apply only once claims exist",
    },
    Axiom {
        name: "hypothesis_generation_skips_present_tense_conflict",
        kind: AxiomKind::PreconditionRequirement,
        left_move_type: Some("hypothesis_generation"),
        right_move_type: None,
        precondition: Some(Precondition::NoContestFlags(0b10000)), // PRESENT_TENSE_CONFLICT bit
        description: "generating new hypotheses over an actively-contested proposition \
                      compounds noise; resolve the present-tense conflict first",
    },
];

/// A violated precondition, returned by `check_move_preconditions`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PreconditionViolation {
    pub axiom_name: &'static str,
    pub move_type: String,
    pub description: &'static str,
    /// Human-readable observation of what was seen vs required.
    pub observation: String,
}

impl crate::engine::YantrikDB {
    /// Return the static axiom registry. Useful for introspection tests
    /// and documentation tools.
    pub fn composition_axioms(&self) -> &'static [Axiom] {
        AXIOMS
    }

    /// Check whether a pair of moves matches any ApproxIdentity or
    /// NonCommutative axiom. Given two move_ids, this looks up their
    /// canonical move_types (applying the latest correction) and then
    /// scans the axiom registry for a match. Returns all matching
    /// axioms — usually zero or one.
    pub fn check_composition_axioms(
        &self,
        left_move_id: &str,
        right_move_id: &str,
    ) -> Result<Vec<&'static Axiom>> {
        let left = self.get_move_event_canonical(left_move_id)?;
        let right = self.get_move_event_canonical(right_move_id)?;
        let (Some(l), Some(r)) = (left, right) else {
            return Ok(Vec::new());
        };
        let matches: Vec<&'static Axiom> = AXIOMS
            .iter()
            .filter(|ax| matches!(ax.kind, AxiomKind::ApproxIdentity | AxiomKind::NonCommutative))
            .filter(|ax| {
                ax.left_move_type == Some(l.move_type.as_str())
                    && ax.right_move_type == Some(r.move_type.as_str())
            })
            .collect();
        Ok(matches)
    }

    /// Check whether a move of the given type would violate any of its
    /// PreconditionRequirement axioms, given the current mobility and
    /// contest state for (proposition_id, regime). Returns a list of
    /// violated preconditions — empty list means the move is coherent.
    ///
    /// Callers typically check this BEFORE recording a move, to either
    /// reject the operation or flag it for review. M8 provides the
    /// mechanism; policy (reject vs warn) is the caller's decision.
    pub fn check_move_preconditions(
        &self,
        move_type: &str,
        proposition_id: &str,
        regime: &str,
    ) -> Result<Vec<PreconditionViolation>> {
        let mobility = self.get_mobility_state(proposition_id, regime)?;
        let contest = self.get_contest_state(proposition_id, regime)?;
        let mut violations = Vec::new();
        for ax in AXIOMS {
            if ax.kind != AxiomKind::PreconditionRequirement {
                continue;
            }
            if ax.left_move_type != Some(move_type) {
                continue;
            }
            let Some(pre) = ax.precondition else { continue };
            let violated = match pre {
                Precondition::SelfGenAncestralBelow(threshold) => {
                    let psi_a = mobility.as_ref().and_then(|m| m.self_gen_ancestral);
                    match psi_a {
                        Some(v) if v < threshold => None,
                        Some(v) => Some(format!(
                            "self_gen_ancestral = {:.3} (required < {})",
                            v, threshold
                        )),
                        None => Some(format!(
                            "self_gen_ancestral not yet computed (required < {}); \
                             run compute_background_mobility first",
                            threshold
                        )),
                    }
                }
                Precondition::SupportMassAbove(threshold) => {
                    let sigma = mobility.as_ref().and_then(|m| m.support_mass);
                    match sigma {
                        Some(v) if v > threshold => None,
                        Some(v) => Some(format!(
                            "support_mass = {:.3} (required > {})",
                            v, threshold
                        )),
                        None => Some(format!(
                            "support_mass not yet computed (required > {})",
                            threshold
                        )),
                    }
                }
                Precondition::NoContestFlags(bitmask) => {
                    let flags = contest.as_ref().map(|c| c.heuristic_flags).unwrap_or(0);
                    if flags & bitmask == 0 {
                        None
                    } else {
                        Some(format!(
                            "contest heuristic_flags = {:#x} intersects required-zero mask {:#x}",
                            flags, bitmask
                        ))
                    }
                }
            };
            if let Some(obs) = violated {
                violations.push(PreconditionViolation {
                    axiom_name: ax.name,
                    move_type: move_type.to_string(),
                    description: ax.description,
                    observation: obs,
                });
            }
        }
        Ok(violations)
    }
}

// ──────────────────────────────────────────────────────────────────
// RFC 008 M9: move_type_profile derivation.
//
// Background loop that folds resolved-or-past-horizon move_events into
// per-(move_type, operator_version, context_regime) distortion profiles.
// Called by app-layer scan or think() integration. Designed to be safe
// under concurrent ingestion: each profile is recomputed from scratch
// on demand, no incremental drift.
// ──────────────────────────────────────────────────────────────────

/// A single distortion profile row. Mirrors the move_type_profile schema.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MoveTypeProfile {
    pub move_type: String,
    pub operator_version: String,
    pub context_regime: String,
    pub uses_count: i64,
    pub resolved_count: i64,
    pub corroborated_count: i64,
    pub retracted_count: i64,
    pub harmful_side_effect_count: i64,
    pub contradiction_introduction_rate: Option<f64>,
    pub avg_mobility_shift: Option<f64>,
    pub predictive_gain_avg: Option<f64>,
    pub calibration_gain_avg: Option<f64>,
    pub last_updated: f64,
}

impl crate::engine::YantrikDB {
    /// Recompute the move_type_profile row for a specific key. Scans all
    /// move_events matching (move_type, operator_version, context_regime)
    /// and aggregates outcomes into counters + rates. Returns the written
    /// profile.
    ///
    /// A move counts as "resolved" when `posthoc_outcome IS NOT NULL` OR
    /// when `created_at + expected_evaluation_horizon_ms` has passed.
    /// The latter is measured against the current wall clock at compute
    /// time — consumers that want strict determinism should pin the
    /// horizon-check logic separately.
    pub fn recompute_move_type_profile(
        &self,
        move_type: &str,
        operator_version: &str,
        context_regime: &str,
    ) -> Result<MoveTypeProfile> {
        let conn = self.conn.lock();
        let now_ms = (crate::engine::now() * 1000.0) as i64;

        // Count uses (all events).
        let uses_count: i64 = conn.query_row(
            "SELECT COUNT(*) FROM move_events \
             WHERE move_type = ?1 AND operator_version = ?2 AND context_regime = ?3",
            params![move_type, operator_version, context_regime],
            |row| row.get(0),
        )?;

        // Count resolved (outcome set OR past horizon).
        let resolved_count: i64 = conn.query_row(
            "SELECT COUNT(*) FROM move_events \
             WHERE move_type = ?1 AND operator_version = ?2 AND context_regime = ?3 \
               AND (posthoc_outcome IS NOT NULL \
                    OR (expected_evaluation_horizon_ms IS NOT NULL \
                        AND (created_at * 1000 + expected_evaluation_horizon_ms) < ?4))",
            params![move_type, operator_version, context_regime, now_ms],
            |row| row.get(0),
        )?;

        // Per-outcome counters.
        let corroborated_count = self.count_by_outcome(&conn, move_type, operator_version, context_regime, "corroborated")?;
        let retracted_count = self.count_by_outcome(&conn, move_type, operator_version, context_regime, "retracted")?;
        let harmful_side_effect_count = self.count_by_outcome(&conn, move_type, operator_version, context_regime, "harmful_side_effect")?;

        // Rates — only well-defined when there are resolved moves.
        let contradiction_introduction_rate = if resolved_count > 0 {
            // Approximation: treat retracted + harmful as "introduced contradiction."
            Some((retracted_count + harmful_side_effect_count) as f64 / resolved_count as f64)
        } else {
            None
        };

        // predictive_gain_avg and calibration_gain_avg come from yield_json;
        // parsing those requires more structure than M9 ships. Leave NULL
        // until a consumer defines the yield_json schema.
        let predictive_gain_avg = None;
        let calibration_gain_avg = None;

        // avg_mobility_shift would come from comparing mobility snapshots
        // before/after moves via the content_hash pointers. Leave NULL
        // until M6+ provides the snapshot timeline.
        let avg_mobility_shift = None;

        let last_updated = crate::engine::now();

        conn.execute(
            "INSERT INTO move_type_profile (\
             move_type, operator_version, context_regime, \
             uses_count, resolved_count, \
             corroborated_count, retracted_count, harmful_side_effect_count, \
             contradiction_introduction_rate, avg_mobility_shift, \
             predictive_gain_avg, calibration_gain_avg, last_updated) \
             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) \
             ON CONFLICT(move_type, operator_version, context_regime) DO UPDATE SET \
             uses_count = excluded.uses_count, \
             resolved_count = excluded.resolved_count, \
             corroborated_count = excluded.corroborated_count, \
             retracted_count = excluded.retracted_count, \
             harmful_side_effect_count = excluded.harmful_side_effect_count, \
             contradiction_introduction_rate = excluded.contradiction_introduction_rate, \
             avg_mobility_shift = excluded.avg_mobility_shift, \
             predictive_gain_avg = excluded.predictive_gain_avg, \
             calibration_gain_avg = excluded.calibration_gain_avg, \
             last_updated = excluded.last_updated",
            params![
                move_type, operator_version, context_regime,
                uses_count, resolved_count,
                corroborated_count, retracted_count, harmful_side_effect_count,
                contradiction_introduction_rate, avg_mobility_shift,
                predictive_gain_avg, calibration_gain_avg, last_updated,
            ],
        )?;

        Ok(MoveTypeProfile {
            move_type: move_type.to_string(),
            operator_version: operator_version.to_string(),
            context_regime: context_regime.to_string(),
            uses_count,
            resolved_count,
            corroborated_count,
            retracted_count,
            harmful_side_effect_count,
            contradiction_introduction_rate,
            avg_mobility_shift,
            predictive_gain_avg,
            calibration_gain_avg,
            last_updated,
        })
    }

    fn count_by_outcome(
        &self,
        conn: &Connection,
        move_type: &str,
        operator_version: &str,
        context_regime: &str,
        outcome: &str,
    ) -> Result<i64> {
        let n: i64 = conn.query_row(
            "SELECT COUNT(*) FROM move_events \
             WHERE move_type = ?1 AND operator_version = ?2 \
               AND context_regime = ?3 AND posthoc_outcome = ?4",
            params![move_type, operator_version, context_regime, outcome],
            |row| row.get(0),
        )?;
        Ok(n)
    }

    /// Scan all distinct (move_type, operator_version, context_regime)
    /// triples present in move_events and recompute each profile.
    /// Returns the number of profiles updated.
    pub fn recompute_all_move_type_profiles(&self) -> Result<usize> {
        let keys = self.list_profile_keys()?;
        let mut count = 0;
        for (mt, op, regime) in keys {
            self.recompute_move_type_profile(&mt, &op, &regime)?;
            count += 1;
        }
        Ok(count)
    }

    fn list_profile_keys(&self) -> Result<Vec<(String, String, String)>> {
        let conn = self.conn.lock();
        let mut stmt = conn.prepare(
            "SELECT DISTINCT move_type, operator_version, context_regime \
             FROM move_events",
        )?;
        let rows = stmt
            .query_map([], |row| {
                Ok((
                    row.get::<_, String>(0)?,
                    row.get::<_, String>(1)?,
                    row.get::<_, String>(2)?,
                ))
            })?
            .collect::<std::result::Result<Vec<_>, _>>()?;
        Ok(rows)
    }

    /// Read a move_type_profile row. None if not yet computed.
    pub fn get_move_type_profile(
        &self,
        move_type: &str,
        operator_version: &str,
        context_regime: &str,
    ) -> Result<Option<MoveTypeProfile>> {
        let conn = self.conn.lock();
        let res = conn.query_row(
            "SELECT move_type, operator_version, context_regime, \
             uses_count, resolved_count, corroborated_count, retracted_count, \
             harmful_side_effect_count, contradiction_introduction_rate, \
             avg_mobility_shift, predictive_gain_avg, calibration_gain_avg, \
             last_updated \
             FROM move_type_profile \
             WHERE move_type = ?1 AND operator_version = ?2 AND context_regime = ?3",
            params![move_type, operator_version, context_regime],
            |row| {
                Ok(MoveTypeProfile {
                    move_type: row.get(0)?,
                    operator_version: row.get(1)?,
                    context_regime: row.get(2)?,
                    uses_count: row.get(3)?,
                    resolved_count: row.get(4)?,
                    corroborated_count: row.get(5)?,
                    retracted_count: row.get(6)?,
                    harmful_side_effect_count: row.get(7)?,
                    contradiction_introduction_rate: row.get(8)?,
                    avg_mobility_shift: row.get(9)?,
                    predictive_gain_avg: row.get(10)?,
                    calibration_gain_avg: row.get(11)?,
                    last_updated: row.get(12)?,
                })
            },
        );
        match res {
            Ok(p) => Ok(Some(p)),
            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
            Err(e) => Err(e.into()),
        }
    }
}

// ──────────────────────────────────────────────────────────────────
// Connection-level bootstrap helper (callable from YantrikDB::new).
// ──────────────────────────────────────────────────────────────────

pub(super) fn seed_registries_inner(conn: &Connection) -> Result<()> {
    let now = super::now();
    for (mt, desc, horizon) in SEED_MOVE_TYPES {
        conn.execute(
            "INSERT OR IGNORE INTO move_type_registry \
             (move_type, status, description, introduced_at, default_expected_evaluation_horizon_ms) \
             VALUES (?1, 'active', ?2, ?3, ?4)",
            params![mt, desc, now, horizon],
        )?;
    }
    for (basis, desc) in SEED_INFERENCE_BASES {
        conn.execute(
            "INSERT OR IGNORE INTO inference_basis_registry \
             (basis_type, description, status) VALUES (?1, ?2, 'active')",
            params![basis, desc],
        )?;
    }
    Ok(())
}

// ──────────────────────────────────────────────────────────────────
// Row decoders.
// ──────────────────────────────────────────────────────────────────

fn read_move_event(conn: &Connection, move_id: &str) -> Result<Option<MoveEvent>> {
    let mut stmt = conn.prepare(
        "SELECT move_id, move_type, operator_version, actor_id, context_regime, \
         observability, inference_confidence, inference_basis_json, dependencies_json, \
         cost_tokens, cost_latency_ms, cost_memory_reads, yield_json, \
         posthoc_outcome, posthoc_recorded_at, expected_evaluation_horizon_ms, \
         mobility_state_hash_at_move, contest_state_hash_at_move, \
         created_at, hlc, origin_actor \
         FROM move_events WHERE move_id = ?1",
    )?;
    let result = stmt.query_row(params![move_id], row_to_move_event);
    match result {
        Ok(e) => Ok(Some(e)),
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
        Err(e) => Err(e.into()),
    }
}

fn row_to_move_event(row: &rusqlite::Row) -> rusqlite::Result<MoveEvent> {
    let hlc_bytes: Vec<u8> = row.get(19)?;
    Ok(MoveEvent {
        move_id: row.get(0)?,
        move_type: row.get(1)?,
        operator_version: row.get(2)?,
        actor_id: row.get(3)?,
        context_regime: row.get(4)?,
        observability: row.get(5)?,
        inference_confidence: row.get(6)?,
        inference_basis_json: row.get(7)?,
        dependencies_json: row.get(8)?,
        cost_tokens: row.get(9)?,
        cost_latency_ms: row.get(10)?,
        cost_memory_reads: row.get(11)?,
        yield_json: row.get(12)?,
        posthoc_outcome: row.get(13)?,
        posthoc_recorded_at: row.get(14)?,
        expected_evaluation_horizon_ms: row.get(15)?,
        mobility_state_hash_at_move: row.get(16)?,
        contest_state_hash_at_move: row.get(17)?,
        created_at: row.get(18)?,
        hlc_hex: hex::encode(&hlc_bytes),
        origin_actor: row.get(20)?,
    })
}

fn row_to_adversarial(row: &rusqlite::Row) -> rusqlite::Result<AdversarialInstance> {
    Ok(AdversarialInstance {
        instance_id: row.get(0)?,
        move_id: row.get(1)?,
        status: row.get(2)?,
        discovered_via: row.get(3)?,
        traced_root_cause: row.get(4)?,
        generalized_lesson: row.get(5)?,
        lesson_scope_json: row.get(6)?,
        curation_actor_id: row.get(7)?,
        discovered_at: row.get(8)?,
        created_at: row.get(9)?,
    })
}