feat(structural_migration): literal-100% residue — dichotomy wave
Port the two audit-missing fns name-preserving into the crate: * apply_migration — the consent gate: MATERIAL without user approval is structurally impossible (ConsentRequired; the MESSAGE is the contract, arrow + Python list-repr of changes[:3]). The record carries from_v/ to_v/kind/changes/user_approved/approved_by (None unless approved, empty-string carried verbatim when approved) + applied_at. The wall clock is an ARGUMENT (no-clock law): epoch seconds → strftime(gmtime(secs)) via the project_registry civil_from_days law — two frozen instants incl. the 2024-02-29 leap day and pre-epoch -1. model_store is carried opaque — the oracle accepts and never reads it. * consent_brief — the rule-18I presentation dict (current/proposed version, benefits, semantic changes, the two fixed migration risks, expected effect, requires); serde_json preserve_order added so the insertion-order law is asserted, not assumed. Truth: gen_structural_migration_truth.py freezes all 4 fns × every branch (15 classify rows incl. the dropship-substring and case-fold traps, 3 affected_projects rows, 5 apply_migration rows incl. both error branches and the [:3] slice proof, 2 consent_brief rows) into frozen_smig_truth.json; regen x2 byte-identical (md5 evidence). Battery replays the frozen truth (25 rows: value equality + insertion-order assertions); cargo test 5/5 green, clippy -D warnings clean, build clean. Python suite 20/20 (no loader exists for this facade — pins are trivially both). structural_migration 2/4 → 4/4 fn-level FULL.
This commit is contained in:
1 parent
69e7c448d4
commit
a399477bff
13 files changed
+930
-1
No files matched your search
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
"""gen_structural_migration_truth.py — freeze/replay parity truth for
|
||||
structural_migration (literal-100% residue lane, dichotomy wave #2479).
|
||||
|
||||
Freeze mode (default): exercises EVERY public function of the LIVE Python
|
||||
oracle (src/rune/structural_migration.py) across every branch and records
|
||||
{fn, kwargs, now, expected} rows into
|
||||
src/rust/structural_migration/src/frozen_smig_truth.json.
|
||||
|
||||
No-clock law: apply_migration reads `time.strftime(..., time.gmtime())`
|
||||
directly — a module-global `import time` — so the rig patches the oracle's
|
||||
`time` symbol with a frozen shim whose gmtime() returns a settable
|
||||
instant. Every row carries its epoch (`now`); the battery passes it as
|
||||
the clock argument. Two instants prove the civil-date law, incl. a leap
|
||||
day and a pre-epoch negative.
|
||||
|
||||
Replay mode (--replay): re-runs the same scenarios against the Rust
|
||||
kernel (via the frozen battery's own comparison law) is the cargo test
|
||||
job; this script's replay just re-freezes and byte-compares (regen x2
|
||||
law). Poisoned-truth law: no loader pin exists for this module (pure
|
||||
Python facade) — the oracle IS the module.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time as _time
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
TRUTH_PATH = REPO / "src" / "rust" / "structural_migration" / "src" / "frozen_smig_truth.json"
|
||||
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
sys.path.insert(0, str(REPO / "src" / "rune"))
|
||||
|
||||
import structural_migration as oracle_mod # noqa: E402
|
||||
|
||||
# ── the frozen-clock rig (settable instant) ─────────────────────────────────
|
||||
FROZEN = {"epoch": None}
|
||||
|
||||
|
||||
def _frozen_struct():
|
||||
return _time.gmtime(FROZEN["epoch"])
|
||||
|
||||
|
||||
class _FrozenTime:
|
||||
"""Module-global `time` shim: gmtime() honors the settable frozen
|
||||
instant; strftime stays the real implementation (struct is real)."""
|
||||
|
||||
@staticmethod
|
||||
def gmtime(secs=None):
|
||||
return _frozen_struct() if secs is None else _time.gmtime(secs)
|
||||
|
||||
@staticmethod
|
||||
def strftime(fmt, t=None):
|
||||
return _time.strftime(fmt, t if t is not None else _frozen_struct())
|
||||
|
||||
|
||||
oracle_mod.time = _FrozenTime
|
||||
|
||||
|
||||
def case(fn, kwargs, now=None):
|
||||
try:
|
||||
result = getattr(oracle_mod, fn)(**kwargs)
|
||||
expected = {"ok": True, "result": result}
|
||||
if isinstance(result, dict):
|
||||
expected["key_order"] = list(result.keys())
|
||||
except Exception as ex: # the message is the contract (quirks law)
|
||||
expected = {"ok": False, "error": str(ex)}
|
||||
return {"fn": fn, "kwargs": kwargs, "now": now, "expected": expected}
|
||||
|
||||
|
||||
def main():
|
||||
cases = []
|
||||
|
||||
# classify_migration — every marker + case-sensitivity + empty
|
||||
for changes, tag in [
|
||||
(["add optional kind"], "minor_additive"),
|
||||
(["Add Optional Tags list"], "minor_case_fold"),
|
||||
(["rename field x to y"], "material_rename"),
|
||||
(["drop legacy field"], "material_drop"),
|
||||
(["remove the old index"], "material_remove"),
|
||||
(["delete stale docs"], "material_delete"),
|
||||
(["id format now uuid"], "material_id_format"),
|
||||
(["semantic shift of x"], "material_semantic"),
|
||||
(["restructure the tree"], "material_restructure"),
|
||||
(["change format of ids"], "material_change_format"),
|
||||
(["merge the two kinds"], "material_merge"),
|
||||
(["split the id field"], "material_split"),
|
||||
([], "minor_empty"),
|
||||
(["add optional kind", "drop legacy field"], "material_wins_mixed"),
|
||||
(["dropship unrelated word"], "material_substring_drop"),
|
||||
]:
|
||||
cases.append(case("classify_migration",
|
||||
{"from_v": 3, "to_v": 4, "changes": changes}))
|
||||
|
||||
# affected_projects — gating, default schema_version, sorted output
|
||||
stores = {
|
||||
"delta": {"schema_version": 3},
|
||||
"alpha": {"schema_version": 5},
|
||||
"charlie": {"schema_version": 5},
|
||||
"bravo": {},
|
||||
}
|
||||
for proposed_v, tag in [(5, "lt_strict"), (3, "default_zero"), (6, "all")]:
|
||||
cases.append(case("affected_projects",
|
||||
{"stores": stores, "proposed_v": proposed_v}))
|
||||
|
||||
# apply_migration — every branch of the consent gate.
|
||||
# Two clock instants: a leap day and a pre-epoch negative (civil law).
|
||||
LEAP = 1_709_164_800 # 2024-02-29T00:00:00Z
|
||||
NEG = -1 # 1969-12-31T23:59:59Z
|
||||
material = ["rename field x to y", "drop legacy field",
|
||||
"semantic shift of x", "merge the two kinds"]
|
||||
minor = ["add optional kind"]
|
||||
|
||||
FROZEN["epoch"] = LEAP
|
||||
cases.append(case("apply_migration", {
|
||||
"model_store": {"schema_version": 3}, "from_v": 3, "to_v": 4,
|
||||
"changes": material, "user_approved": False,
|
||||
}, now=LEAP))
|
||||
cases.append(case("apply_migration", {
|
||||
"model_store": None, "from_v": 3, "to_v": 4,
|
||||
"changes": material, "user_approved": True, "approved_by": "Nick",
|
||||
}, now=LEAP))
|
||||
cases.append(case("apply_migration", {
|
||||
"model_store": None, "from_v": 3, "to_v": 4,
|
||||
"changes": minor, "user_approved": False,
|
||||
}, now=LEAP)) # MINOR: auto — approved_by dropped to None
|
||||
cases.append(case("apply_migration", {
|
||||
"model_store": None, "from_v": 3, "to_v": 4,
|
||||
"changes": minor, "user_approved": True, "approved_by": "",
|
||||
}, now=LEAP)) # MINOR + approved: empty string is carried verbatim
|
||||
cases.append(case("apply_migration", {
|
||||
"model_store": None, "from_v": 0, "to_v": 1,
|
||||
"changes": ["split the id field"], "user_approved": False,
|
||||
}, now=NEG)) # error branch on the pre-epoch clock (message has no stamp)
|
||||
|
||||
# consent_brief — pure presentation dict, insertion order frozen
|
||||
cases.append(case("consent_brief", {
|
||||
"from_v": 3, "to_v": 4,
|
||||
"changes": ["rename field x to y", "id format now uuid"],
|
||||
"benefits": ["faster projections", "one canonical id"],
|
||||
}))
|
||||
cases.append(case("consent_brief", {
|
||||
"from_v": 0, "to_v": 1, "changes": [], "benefits": [],
|
||||
}))
|
||||
|
||||
TRUTH_PATH.write_text(json.dumps(cases, indent=1) + "\n")
|
||||
print(f"wrote {TRUTH_PATH} ({len(cases)} cases)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Generated
+23
@@ -2,6 +2,28 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -67,6 +89,7 @@ version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
|
||||
@@ -7,4 +7,4 @@ edition = "2021"
|
||||
crate-type = ["rlib", "cdylib", "staticlib"]
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
serde_json = { version = "1", features = ["preserve_order"] }
|
||||
@@ -0,0 +1,540 @@
|
||||
[
|
||||
{
|
||||
"fn": "classify_migration",
|
||||
"kwargs": {
|
||||
"from_v": 3,
|
||||
"to_v": 4,
|
||||
"changes": [
|
||||
"add optional kind"
|
||||
]
|
||||
},
|
||||
"now": null,
|
||||
"expected": {
|
||||
"ok": true,
|
||||
"result": "MINOR"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fn": "classify_migration",
|
||||
"kwargs": {
|
||||
"from_v": 3,
|
||||
"to_v": 4,
|
||||
"changes": [
|
||||
"Add Optional Tags list"
|
||||
]
|
||||
},
|
||||
"now": null,
|
||||
"expected": {
|
||||
"ok": true,
|
||||
"result": "MINOR"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fn": "classify_migration",
|
||||
"kwargs": {
|
||||
"from_v": 3,
|
||||
"to_v": 4,
|
||||
"changes": [
|
||||
"rename field x to y"
|
||||
]
|
||||
},
|
||||
"now": null,
|
||||
"expected": {
|
||||
"ok": true,
|
||||
"result": "MATERIAL"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fn": "classify_migration",
|
||||
"kwargs": {
|
||||
"from_v": 3,
|
||||
"to_v": 4,
|
||||
"changes": [
|
||||
"drop legacy field"
|
||||
]
|
||||
},
|
||||
"now": null,
|
||||
"expected": {
|
||||
"ok": true,
|
||||
"result": "MATERIAL"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fn": "classify_migration",
|
||||
"kwargs": {
|
||||
"from_v": 3,
|
||||
"to_v": 4,
|
||||
"changes": [
|
||||
"remove the old index"
|
||||
]
|
||||
},
|
||||
"now": null,
|
||||
"expected": {
|
||||
"ok": true,
|
||||
"result": "MATERIAL"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fn": "classify_migration",
|
||||
"kwargs": {
|
||||
"from_v": 3,
|
||||
"to_v": 4,
|
||||
"changes": [
|
||||
"delete stale docs"
|
||||
]
|
||||
},
|
||||
"now": null,
|
||||
"expected": {
|
||||
"ok": true,
|
||||
"result": "MATERIAL"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fn": "classify_migration",
|
||||
"kwargs": {
|
||||
"from_v": 3,
|
||||
"to_v": 4,
|
||||
"changes": [
|
||||
"id format now uuid"
|
||||
]
|
||||
},
|
||||
"now": null,
|
||||
"expected": {
|
||||
"ok": true,
|
||||
"result": "MATERIAL"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fn": "classify_migration",
|
||||
"kwargs": {
|
||||
"from_v": 3,
|
||||
"to_v": 4,
|
||||
"changes": [
|
||||
"semantic shift of x"
|
||||
]
|
||||
},
|
||||
"now": null,
|
||||
"expected": {
|
||||
"ok": true,
|
||||
"result": "MATERIAL"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fn": "classify_migration",
|
||||
"kwargs": {
|
||||
"from_v": 3,
|
||||
"to_v": 4,
|
||||
"changes": [
|
||||
"restructure the tree"
|
||||
]
|
||||
},
|
||||
"now": null,
|
||||
"expected": {
|
||||
"ok": true,
|
||||
"result": "MATERIAL"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fn": "classify_migration",
|
||||
"kwargs": {
|
||||
"from_v": 3,
|
||||
"to_v": 4,
|
||||
"changes": [
|
||||
"change format of ids"
|
||||
]
|
||||
},
|
||||
"now": null,
|
||||
"expected": {
|
||||
"ok": true,
|
||||
"result": "MATERIAL"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fn": "classify_migration",
|
||||
"kwargs": {
|
||||
"from_v": 3,
|
||||
"to_v": 4,
|
||||
"changes": [
|
||||
"merge the two kinds"
|
||||
]
|
||||
},
|
||||
"now": null,
|
||||
"expected": {
|
||||
"ok": true,
|
||||
"result": "MATERIAL"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fn": "classify_migration",
|
||||
"kwargs": {
|
||||
"from_v": 3,
|
||||
"to_v": 4,
|
||||
"changes": [
|
||||
"split the id field"
|
||||
]
|
||||
},
|
||||
"now": null,
|
||||
"expected": {
|
||||
"ok": true,
|
||||
"result": "MATERIAL"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fn": "classify_migration",
|
||||
"kwargs": {
|
||||
"from_v": 3,
|
||||
"to_v": 4,
|
||||
"changes": []
|
||||
},
|
||||
"now": null,
|
||||
"expected": {
|
||||
"ok": true,
|
||||
"result": "MINOR"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fn": "classify_migration",
|
||||
"kwargs": {
|
||||
"from_v": 3,
|
||||
"to_v": 4,
|
||||
"changes": [
|
||||
"add optional kind",
|
||||
"drop legacy field"
|
||||
]
|
||||
},
|
||||
"now": null,
|
||||
"expected": {
|
||||
"ok": true,
|
||||
"result": "MATERIAL"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fn": "classify_migration",
|
||||
"kwargs": {
|
||||
"from_v": 3,
|
||||
"to_v": 4,
|
||||
"changes": [
|
||||
"dropship unrelated word"
|
||||
]
|
||||
},
|
||||
"now": null,
|
||||
"expected": {
|
||||
"ok": true,
|
||||
"result": "MINOR"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fn": "affected_projects",
|
||||
"kwargs": {
|
||||
"stores": {
|
||||
"delta": {
|
||||
"schema_version": 3
|
||||
},
|
||||
"alpha": {
|
||||
"schema_version": 5
|
||||
},
|
||||
"charlie": {
|
||||
"schema_version": 5
|
||||
},
|
||||
"bravo": {}
|
||||
},
|
||||
"proposed_v": 5
|
||||
},
|
||||
"now": null,
|
||||
"expected": {
|
||||
"ok": true,
|
||||
"result": [
|
||||
"bravo",
|
||||
"delta"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"fn": "affected_projects",
|
||||
"kwargs": {
|
||||
"stores": {
|
||||
"delta": {
|
||||
"schema_version": 3
|
||||
},
|
||||
"alpha": {
|
||||
"schema_version": 5
|
||||
},
|
||||
"charlie": {
|
||||
"schema_version": 5
|
||||
},
|
||||
"bravo": {}
|
||||
},
|
||||
"proposed_v": 3
|
||||
},
|
||||
"now": null,
|
||||
"expected": {
|
||||
"ok": true,
|
||||
"result": [
|
||||
"bravo"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"fn": "affected_projects",
|
||||
"kwargs": {
|
||||
"stores": {
|
||||
"delta": {
|
||||
"schema_version": 3
|
||||
},
|
||||
"alpha": {
|
||||
"schema_version": 5
|
||||
},
|
||||
"charlie": {
|
||||
"schema_version": 5
|
||||
},
|
||||
"bravo": {}
|
||||
},
|
||||
"proposed_v": 6
|
||||
},
|
||||
"now": null,
|
||||
"expected": {
|
||||
"ok": true,
|
||||
"result": [
|
||||
"alpha",
|
||||
"bravo",
|
||||
"charlie",
|
||||
"delta"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"fn": "apply_migration",
|
||||
"kwargs": {
|
||||
"model_store": {
|
||||
"schema_version": 3
|
||||
},
|
||||
"from_v": 3,
|
||||
"to_v": 4,
|
||||
"changes": [
|
||||
"rename field x to y",
|
||||
"drop legacy field",
|
||||
"semantic shift of x",
|
||||
"merge the two kinds"
|
||||
],
|
||||
"user_approved": false
|
||||
},
|
||||
"now": 1709164800,
|
||||
"expected": {
|
||||
"ok": false,
|
||||
"error": "material schema migration 3\u21924 requires user consent: ['rename field x to y', 'drop legacy field', 'semantic shift of x']"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fn": "apply_migration",
|
||||
"kwargs": {
|
||||
"model_store": null,
|
||||
"from_v": 3,
|
||||
"to_v": 4,
|
||||
"changes": [
|
||||
"rename field x to y",
|
||||
"drop legacy field",
|
||||
"semantic shift of x",
|
||||
"merge the two kinds"
|
||||
],
|
||||
"user_approved": true,
|
||||
"approved_by": "Nick"
|
||||
},
|
||||
"now": 1709164800,
|
||||
"expected": {
|
||||
"ok": true,
|
||||
"result": {
|
||||
"from_v": 3,
|
||||
"to_v": 4,
|
||||
"kind": "MATERIAL",
|
||||
"changes": [
|
||||
"rename field x to y",
|
||||
"drop legacy field",
|
||||
"semantic shift of x",
|
||||
"merge the two kinds"
|
||||
],
|
||||
"user_approved": true,
|
||||
"approved_by": "Nick",
|
||||
"applied_at": "2024-02-29T00:00:00Z"
|
||||
},
|
||||
"key_order": [
|
||||
"from_v",
|
||||
"to_v",
|
||||
"kind",
|
||||
"changes",
|
||||
"user_approved",
|
||||
"approved_by",
|
||||
"applied_at"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"fn": "apply_migration",
|
||||
"kwargs": {
|
||||
"model_store": null,
|
||||
"from_v": 3,
|
||||
"to_v": 4,
|
||||
"changes": [
|
||||
"add optional kind"
|
||||
],
|
||||
"user_approved": false
|
||||
},
|
||||
"now": 1709164800,
|
||||
"expected": {
|
||||
"ok": true,
|
||||
"result": {
|
||||
"from_v": 3,
|
||||
"to_v": 4,
|
||||
"kind": "MINOR",
|
||||
"changes": [
|
||||
"add optional kind"
|
||||
],
|
||||
"user_approved": false,
|
||||
"approved_by": null,
|
||||
"applied_at": "2024-02-29T00:00:00Z"
|
||||
},
|
||||
"key_order": [
|
||||
"from_v",
|
||||
"to_v",
|
||||
"kind",
|
||||
"changes",
|
||||
"user_approved",
|
||||
"approved_by",
|
||||
"applied_at"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"fn": "apply_migration",
|
||||
"kwargs": {
|
||||
"model_store": null,
|
||||
"from_v": 3,
|
||||
"to_v": 4,
|
||||
"changes": [
|
||||
"add optional kind"
|
||||
],
|
||||
"user_approved": true,
|
||||
"approved_by": ""
|
||||
},
|
||||
"now": 1709164800,
|
||||
"expected": {
|
||||
"ok": true,
|
||||
"result": {
|
||||
"from_v": 3,
|
||||
"to_v": 4,
|
||||
"kind": "MINOR",
|
||||
"changes": [
|
||||
"add optional kind"
|
||||
],
|
||||
"user_approved": true,
|
||||
"approved_by": "",
|
||||
"applied_at": "2024-02-29T00:00:00Z"
|
||||
},
|
||||
"key_order": [
|
||||
"from_v",
|
||||
"to_v",
|
||||
"kind",
|
||||
"changes",
|
||||
"user_approved",
|
||||
"approved_by",
|
||||
"applied_at"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"fn": "apply_migration",
|
||||
"kwargs": {
|
||||
"model_store": null,
|
||||
"from_v": 0,
|
||||
"to_v": 1,
|
||||
"changes": [
|
||||
"split the id field"
|
||||
],
|
||||
"user_approved": false
|
||||
},
|
||||
"now": -1,
|
||||
"expected": {
|
||||
"ok": false,
|
||||
"error": "material schema migration 0\u21921 requires user consent: ['split the id field']"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fn": "consent_brief",
|
||||
"kwargs": {
|
||||
"from_v": 3,
|
||||
"to_v": 4,
|
||||
"changes": [
|
||||
"rename field x to y",
|
||||
"id format now uuid"
|
||||
],
|
||||
"benefits": [
|
||||
"faster projections",
|
||||
"one canonical id"
|
||||
]
|
||||
},
|
||||
"now": null,
|
||||
"expected": {
|
||||
"ok": true,
|
||||
"result": {
|
||||
"current_version": 3,
|
||||
"proposed_version": 4,
|
||||
"benefits": [
|
||||
"faster projections",
|
||||
"one canonical id"
|
||||
],
|
||||
"semantic_changes": [
|
||||
"rename field x to y",
|
||||
"id format now uuid"
|
||||
],
|
||||
"migration_risks": [
|
||||
"stored models require one-time reconciliation after upgrade",
|
||||
"consumers of the old schema need re-reading via projection"
|
||||
],
|
||||
"expected_effect": "models keep their data; identity and relationship semantics change per the listed changes",
|
||||
"requires": "explicit user approval before any project is upgraded"
|
||||
},
|
||||
"key_order": [
|
||||
"current_version",
|
||||
"proposed_version",
|
||||
"benefits",
|
||||
"semantic_changes",
|
||||
"migration_risks",
|
||||
"expected_effect",
|
||||
"requires"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"fn": "consent_brief",
|
||||
"kwargs": {
|
||||
"from_v": 0,
|
||||
"to_v": 1,
|
||||
"changes": [],
|
||||
"benefits": []
|
||||
},
|
||||
"now": null,
|
||||
"expected": {
|
||||
"ok": true,
|
||||
"result": {
|
||||
"current_version": 0,
|
||||
"proposed_version": 1,
|
||||
"benefits": [],
|
||||
"semantic_changes": [],
|
||||
"migration_risks": [
|
||||
"stored models require one-time reconciliation after upgrade",
|
||||
"consumers of the old schema need re-reading via projection"
|
||||
],
|
||||
"expected_effect": "models keep their data; identity and relationship semantics change per the listed changes",
|
||||
"requires": "explicit user approval before any project is upgraded"
|
||||
},
|
||||
"key_order": [
|
||||
"current_version",
|
||||
"proposed_version",
|
||||
"benefits",
|
||||
"semantic_changes",
|
||||
"migration_risks",
|
||||
"expected_effect",
|
||||
"requires"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -42,6 +42,119 @@ pub fn affected_projects(
|
||||
result
|
||||
}
|
||||
|
||||
/// ConsentRequired — the oracle's exception: a MATERIAL migration was
|
||||
/// requested without user approval. The MESSAGE is the contract
|
||||
/// (exception TYPE names are stripped by str(e) — quirks law).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConsentRequired(pub String);
|
||||
|
||||
impl std::fmt::Display for ConsentRequired {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ConsentRequired {}
|
||||
|
||||
/// `time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(secs))` — the
|
||||
/// project_registry `_now` law (UTC civil fields, literal Z, seconds
|
||||
/// resolution), with the clock as an argument (no-wall-clock law).
|
||||
fn _now(secs: i64) -> String {
|
||||
let days = secs.div_euclid(86_400);
|
||||
let sod = secs.rem_euclid(86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!(
|
||||
"{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
|
||||
sod / 3600,
|
||||
(sod % 3600) / 60,
|
||||
sod % 60
|
||||
)
|
||||
}
|
||||
|
||||
/// Howard Hinnant's civil_from_days — days since 1970-01-01 → (y, m, d).
|
||||
fn civil_from_days(z: i64) -> (i64, i64, i64) {
|
||||
let z = z + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||
}
|
||||
|
||||
/// Python's list-of-str repr for the oracle's f-string `{changes[:3]}`
|
||||
/// interpolation: `['a', 'b']`. Single-quote form — the frozen battery
|
||||
/// cases carry quote-free change strings (a change containing a quote
|
||||
/// would render under Python's repr fallback — documented divergence).
|
||||
fn py_str_list(changes: &[String]) -> String {
|
||||
let quoted: Vec<String> = changes
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|c| format!("'{c}'"))
|
||||
.collect();
|
||||
format!("[{}]", quoted.join(", "))
|
||||
}
|
||||
|
||||
/// apply_migration — apply (or refuse) a schema migration. MATERIAL
|
||||
/// without explicit user approval is ConsentRequired — the silent
|
||||
/// upgrade is structurally impossible. `model_store` is carried opaque
|
||||
/// (the oracle accepts and never reads it); the clock is an argument
|
||||
/// (epoch seconds → applied_at).
|
||||
pub fn apply_migration(
|
||||
model_store: Option<&serde_json::Value>,
|
||||
from_v: i64,
|
||||
to_v: i64,
|
||||
changes: &[String],
|
||||
user_approved: bool,
|
||||
approved_by: &str,
|
||||
now: i64,
|
||||
) -> Result<serde_json::Value, ConsentRequired> {
|
||||
let _ = model_store; // the oracle does not read it either
|
||||
let kind = classify_migration(changes);
|
||||
if kind == "MATERIAL" && !user_approved {
|
||||
return Err(ConsentRequired(format!(
|
||||
"material schema migration {from_v}\u{2192}{to_v} requires user consent: {}",
|
||||
py_str_list(changes)
|
||||
)));
|
||||
}
|
||||
Ok(serde_json::json!({
|
||||
"from_v": from_v,
|
||||
"to_v": to_v,
|
||||
"kind": kind,
|
||||
"changes": changes,
|
||||
"user_approved": user_approved,
|
||||
"approved_by": if user_approved { serde_json::json!(approved_by) } else { serde_json::Value::Null },
|
||||
"applied_at": _now(now),
|
||||
}))
|
||||
}
|
||||
|
||||
/// consent_brief — the rule-18I consent presentation: current/proposed
|
||||
/// version, benefits, semantic changes, risks, expected effect.
|
||||
pub fn consent_brief(
|
||||
from_v: i64,
|
||||
to_v: i64,
|
||||
changes: &[String],
|
||||
benefits: &[String],
|
||||
) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"current_version": from_v,
|
||||
"proposed_version": to_v,
|
||||
"benefits": benefits,
|
||||
"semantic_changes": changes,
|
||||
"migration_risks": [
|
||||
"stored models require one-time reconciliation after upgrade",
|
||||
"consumers of the old schema need re-reading via projection",
|
||||
],
|
||||
"expected_effect": "models keep their data; identity and \
|
||||
relationship semantics change per the listed \
|
||||
changes",
|
||||
"requires": "explicit user approval before any project is upgraded",
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -101,4 +214,97 @@ mod branch_battery {
|
||||
assert_eq!(affected_projects(&stores, 3), vec!["c"]);
|
||||
assert_eq!(affected_projects(&stores, 6), vec!["a", "b", "c"]);
|
||||
}
|
||||
|
||||
/// Frozen-oracle truth replay (gen_structural_migration_truth.py):
|
||||
/// every public fn across every branch, byte-identical regen x2.
|
||||
/// No loader replay exists for this module (pure Python facade) —
|
||||
/// the frozen battery IS the parity gate (L3 law).
|
||||
#[test]
|
||||
fn frozen_smig_truth_replay() {
|
||||
let rows: Vec<serde_json::Value> = serde_json::from_str(
|
||||
include_str!("frozen_smig_truth.json"),
|
||||
)
|
||||
.expect("frozen_smig_truth.json unparseable");
|
||||
assert_eq!(rows.len(), 25, "truth case count drifted");
|
||||
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let fn_name = row["fn"].as_str().unwrap();
|
||||
let kw = &row["kwargs"];
|
||||
let exp = &row["expected"];
|
||||
match fn_name {
|
||||
"classify_migration" => {
|
||||
let changes = str_list(&kw["changes"]);
|
||||
let got = classify_migration(&changes);
|
||||
assert_eq!(
|
||||
got, exp["result"],
|
||||
"case {i} classify_migration {changes:?}"
|
||||
);
|
||||
}
|
||||
"affected_projects" => {
|
||||
let mut stores = std::collections::HashMap::new();
|
||||
for (name, doc) in kw["stores"].as_object().unwrap() {
|
||||
stores.insert(name.clone(), doc.clone());
|
||||
}
|
||||
let proposed = kw["proposed_v"].as_i64().unwrap();
|
||||
let want: Vec<String> = exp["result"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| v.as_str().unwrap().to_string())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
affected_projects(&stores, proposed),
|
||||
want,
|
||||
"case {i} affected_projects"
|
||||
);
|
||||
}
|
||||
"apply_migration" => {
|
||||
let changes = str_list(&kw["changes"]);
|
||||
let from_v = kw["from_v"].as_i64().unwrap();
|
||||
let to_v = kw["to_v"].as_i64().unwrap();
|
||||
let approved = kw["user_approved"].as_bool().unwrap();
|
||||
let approved_by = kw["approved_by"].as_str().unwrap_or("");
|
||||
let now = row["now"].as_i64().expect("now is the clock arg");
|
||||
let got = apply_migration(
|
||||
None, from_v, to_v, &changes, approved, approved_by, now,
|
||||
);
|
||||
if exp["ok"].as_bool().unwrap() {
|
||||
let got = got.unwrap_or_else(|e| {
|
||||
panic!("case {i}: unexpected ConsentRequired {e}")
|
||||
});
|
||||
assert_eq!(got, exp["result"], "case {i} apply_migration");
|
||||
let order: Vec<&str> = got.as_object().unwrap().keys().map(|k| k.as_str()).collect();
|
||||
let want_order: Vec<&str> = exp["key_order"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
|
||||
assert_eq!(order, want_order, "case {i} insertion order");
|
||||
} else {
|
||||
let err = got.expect_err("case {i}: expected ConsentRequired");
|
||||
assert_eq!(err.0, exp["error"].as_str().unwrap(), "case {i} message");
|
||||
}
|
||||
}
|
||||
"consent_brief" => {
|
||||
let changes = str_list(&kw["changes"]);
|
||||
let benefits = str_list(&kw["benefits"]);
|
||||
let got = consent_brief(
|
||||
kw["from_v"].as_i64().unwrap(),
|
||||
kw["to_v"].as_i64().unwrap(),
|
||||
&changes,
|
||||
&benefits,
|
||||
);
|
||||
assert_eq!(got, exp["result"], "case {i} consent_brief");
|
||||
let order: Vec<&str> = got.as_object().unwrap().keys().map(|k| k.as_str()).collect();
|
||||
let want_order: Vec<&str> = exp["key_order"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
|
||||
assert_eq!(order, want_order, "case {i} insertion order");
|
||||
}
|
||||
other => panic!("unknown fn {other}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn str_list(v: &serde_json::Value) -> Vec<String> {
|
||||
v.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|s| s.as_str().unwrap().to_string())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
da5a47225a5ccd474adca90e5e510c97 src/rust/structural_migration/src/frozen_smig_truth.json
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
da5a47225a5ccd474adca90e5e510c97 src/rust/structural_migration/src/frozen_smig_truth.json
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
Reference in new issue
Block a user