feat(time_awareness): port to FULL Rust — dichotomy wave
This commit is contained in:
1 parent
36ac3de022
commit
92107319d2
10 files changed
+3769
-92
No files matched your search
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Live parity gate for the time_awareness dichotomy port (#2479 tranche).
|
||||
|
||||
Three legs, every observation compared EXACTLY (the clock is pinned, so
|
||||
nothing is masked):
|
||||
|
||||
1. frozen truth replay on the pinned-python path (truth self-consistency)
|
||||
2. frozen truth replay on the kernel path (RUNE_TA_CORE unset)
|
||||
3. seeded fuzz (seed 2479): random scenarios + random temporal-reference
|
||||
texts, each driven through the live facade twice — pinned python vs
|
||||
kernel — results, state and file must match bit-for-bit.
|
||||
|
||||
Exit 0 only on 100% parity.
|
||||
"""
|
||||
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_ROOT = os.path.dirname(_HERE)
|
||||
sys.path.insert(0, _HERE)
|
||||
sys.path.insert(0, os.path.join(_ROOT, "src"))
|
||||
|
||||
import gen_time_awareness_truth as gen # noqa: E402 — shared clock-pinned harness
|
||||
from rune import ta_loader # noqa: E402
|
||||
|
||||
TRUTH_PATH = os.path.join(_ROOT, "src", "rust", "time_awareness", "src", "frozen_ta_truth.json")
|
||||
|
||||
|
||||
def drive_pin(scenarios, pin, workdir):
|
||||
os.environ["RUNE_TA_CORE"] = pin
|
||||
driver = gen.Driver(workdir)
|
||||
return [driver.run(s["ops"]) for s in scenarios]
|
||||
|
||||
|
||||
def replay_truth(workdir):
|
||||
import json
|
||||
with open(TRUTH_PATH) as f:
|
||||
truth = json.load(f)
|
||||
scenarios = truth["scenarios"]
|
||||
total = sum(len(s["obs"]) for s in scenarios)
|
||||
py = drive_pin(scenarios, "python", os.path.join(workdir, "py"))
|
||||
rs = drive_pin(scenarios, "auto", os.path.join(workdir, "rs"))
|
||||
ok = 0
|
||||
for s, a, b in zip(scenarios, py, rs):
|
||||
for i, (x, y) in enumerate(zip(a, b)):
|
||||
if x == y:
|
||||
ok += 1
|
||||
else:
|
||||
print(f"DIVERGE {s['name']} op#{i} ({s['ops'][i].get('op')})")
|
||||
print(" py:", x)
|
||||
print(" rs:", y)
|
||||
return ok, total
|
||||
|
||||
|
||||
def fuzz_texts(rng, n):
|
||||
words = ["yesterday", "today", "tomorrow", "last", "next", "this", "morning",
|
||||
"afternoon", "tonight", "week", "month", "yesterdays", "mondays",
|
||||
"café", "lastweek", "soon", "later", "now", "the", "a"]
|
||||
seps = [" ", " ", "\t", "\n", ", ", ". "]
|
||||
out = []
|
||||
for _ in range(n):
|
||||
k = rng.randint(1, 6)
|
||||
out.append(rng.choice(seps).join(rng.choice(words) for _ in range(k)))
|
||||
return out
|
||||
|
||||
|
||||
def fuzz_iso(rng, valid_only=False):
|
||||
import datetime as _dt
|
||||
r = rng.random() * (0.8 if valid_only else 1.0)
|
||||
if r < 0.55:
|
||||
dt = gen.EPOCH + _dt.timedelta(microseconds=rng.randint(0, gen.BASE_US))
|
||||
dt += _dt.timedelta(days=rng.randint(-500, 500))
|
||||
off = rng.choice([None, 0, 3600, -18000])
|
||||
if off is not None:
|
||||
dt = dt.replace(tzinfo=_dt.timezone(_dt.timedelta(seconds=off)))
|
||||
return dt.isoformat()
|
||||
if r < 0.7:
|
||||
dt = gen.EPOCH + _dt.timedelta(microseconds=rng.randint(0, gen.BASE_US))
|
||||
return dt.replace(tzinfo=None).isoformat() # naive → TypeError branch
|
||||
if r < 0.8:
|
||||
return "2026-10-10T10:00:00Z"
|
||||
return rng.choice(["garbage", "", "2026-13-40T99:00:00+00:00", "[1,2]"])
|
||||
|
||||
|
||||
def fuzz_scenarios(rng, n, texts):
|
||||
scenarios = []
|
||||
for i in range(n):
|
||||
ops = [{"op": "new", "mono": round(rng.uniform(0.0, 400.0), 3)}]
|
||||
if rng.random() < 0.5:
|
||||
ops[0]["seed_file"] = gen.json.dumps(
|
||||
{"last_interaction": fuzz_iso(rng)}, indent=2
|
||||
)
|
||||
elif rng.random() < 0.1:
|
||||
ops[0]["seed_file"] = rng.choice(["corrupt{", "[1,2]"])
|
||||
for _ in range(rng.randint(1, 8)):
|
||||
kind = rng.choice(["new", "new", "seed_last", "ctx", "ctx", "status",
|
||||
"parse", "flush", "mark_dirty", "save_if_dirty"])
|
||||
op = {"op": kind, "mono": round(rng.uniform(0.0, 400.0), 3)}
|
||||
if kind == "new":
|
||||
if rng.random() < 0.5:
|
||||
op["seed_file"] = gen.json.dumps(
|
||||
{"last_interaction": fuzz_iso(rng)}, indent=2
|
||||
)
|
||||
elif rng.random() < 0.1:
|
||||
op["seed_file"] = rng.choice(["corrupt{", "[1,2]"])
|
||||
if kind == "seed_last":
|
||||
op["last_interaction"] = fuzz_iso(rng, valid_only=True) if rng.random() < 0.8 else None
|
||||
if kind in ("ctx", "status", "parse"):
|
||||
op["now_us"] = gen.BASE_US + rng.randint(-3000, 3000) * 3600 * 1_000_000
|
||||
if kind == "ctx":
|
||||
op["update"] = rng.random() < 0.6
|
||||
if kind == "parse":
|
||||
op["text"] = rng.choice(texts)
|
||||
op["now_us"] = gen.BASE_US + rng.randint(-3000, 3000) * 3600 * 1_000_000
|
||||
ops.append(op)
|
||||
scenarios.append({"name": f"fuzz_{i}", "ops": ops})
|
||||
return scenarios
|
||||
|
||||
|
||||
def main():
|
||||
import shutil
|
||||
work = "/tmp/ta_parity_work"
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
ok, total = replay_truth(work)
|
||||
print(f"truth replay parity: {ok}/{total}")
|
||||
|
||||
rng = random.Random(2479)
|
||||
texts = fuzz_texts(rng, 300)
|
||||
scenarios = fuzz_scenarios(rng, 200, texts)
|
||||
fz_total = sum(len(s["ops"]) for s in scenarios)
|
||||
py = drive_pin(scenarios, "python", os.path.join(work, "fpy"))
|
||||
rs = drive_pin(scenarios, "auto", os.path.join(work, "frs"))
|
||||
fz_ok = 0
|
||||
for s, a, b in zip(scenarios, py, rs):
|
||||
for i, (x, y) in enumerate(zip(a, b)):
|
||||
if x == y:
|
||||
fz_ok += 1
|
||||
else:
|
||||
print(f"DIVERGE {s['name']} op#{i} ({s['ops'][i].get('op')})")
|
||||
print(" py:", x)
|
||||
print(" rs:", y)
|
||||
print(f"fuzz parity (seed 2479): {fz_ok}/{fz_total}")
|
||||
|
||||
if ok == total and fz_ok == fz_total:
|
||||
print(f"PARITY OK — {ok + fz_ok}/{total + fz_total} observations, 100%")
|
||||
return 0
|
||||
print("PARITY FAILED")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,389 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Freeze the time_awareness truth corpus (dichotomy wave, #2479 tranche).
|
||||
|
||||
Drives the LIVE python oracle (src/rune/time_awareness.py) with the clock
|
||||
pinned (fake datetime.now + fake monotonic — RUNE_TA_CORE=python is pinned
|
||||
at the top, poisoned-truth law) and freezes every observation into
|
||||
src/rust/time_awareness/src/frozen_ta_truth.json.
|
||||
|
||||
Coverage: ALL public functions and every branch —
|
||||
* get_context: 14-point time_of_day × circadian grid (weekend + weekday),
|
||||
update_interaction True/False, urgency branches (>24h, ==24h, >4h,
|
||||
==4h, 0.1h..4h, ==0.1h, <0.1h, zero, future), save-debounce fire/no-fire
|
||||
* parse_temporal_reference: all 10 patterns, priority order, word-boundary
|
||||
positives/negatives, case, punctuation, unicode, whitespace runs
|
||||
* get_status (read-only), flush, _mark_dirty, _save_if_dirty
|
||||
* _load: valid aware/no-fraction/Z/offset/naive ISO, null, missing key,
|
||||
corrupt JSON, non-dict JSON, invalid ISO (blanket except → None)
|
||||
* module singleton fns (get_time_awareness/get_context/get_status/
|
||||
parse_temporal_reference) + main() CLI — facade-level, live parity
|
||||
|
||||
Timestamps need NO masking: the clock is pinned, so every field
|
||||
(current_time, dates, hours_since, last_interaction, last_save_time) is
|
||||
deterministic and frozen verbatim.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_ROOT = os.path.dirname(_HERE)
|
||||
sys.path.insert(0, os.path.join(_ROOT, "src"))
|
||||
|
||||
os.environ["RUNE_TA_CORE"] = "python" # poisoned-truth law: pin the fallback
|
||||
os.environ["RUNE_MEMORY_DIR"] = "/tmp/ta_truth_memory"
|
||||
|
||||
import rune.time_awareness as ta # noqa: E402
|
||||
|
||||
EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class _FakeNow:
|
||||
"""Stands in for the module's `datetime` name — .now(tz) is pinned;
|
||||
everything else delegates to the real class (fromisoformat in _load)."""
|
||||
|
||||
fixed = None
|
||||
|
||||
@staticmethod
|
||||
def now(tz=None):
|
||||
return _FakeNow.fixed
|
||||
|
||||
@staticmethod
|
||||
def fromisoformat(s):
|
||||
return datetime.fromisoformat(s)
|
||||
|
||||
|
||||
class _FakeTime:
|
||||
mono = 0.0
|
||||
|
||||
@staticmethod
|
||||
def monotonic():
|
||||
return _FakeTime.mono
|
||||
|
||||
|
||||
ta.datetime = _FakeNow
|
||||
ta._time = _FakeTime
|
||||
|
||||
|
||||
def us_of(dt):
|
||||
return (dt - EPOCH) // timedelta(microseconds=1)
|
||||
|
||||
|
||||
def dt_of(us, micro=None):
|
||||
dt = EPOCH + timedelta(microseconds=us)
|
||||
return dt
|
||||
|
||||
|
||||
def set_clock(op):
|
||||
us = op.get("now_us")
|
||||
_FakeNow.fixed = dt_of(us) if us is not None else _FakeNow.fixed
|
||||
if "mono" in op:
|
||||
_FakeTime.mono = op["mono"]
|
||||
|
||||
|
||||
def iso_of(us):
|
||||
return dt_of(us).isoformat()
|
||||
|
||||
|
||||
BASE_US = us_of(datetime(2026, 10, 11, 12, 0, 0, 123456, tzinfo=timezone.utc))
|
||||
HOUR_US = 3600 * 1_000_000
|
||||
MEM_FILE = os.path.join("/tmp/ta_truth_memory", "time_awareness.json")
|
||||
|
||||
|
||||
def state_obs(instance):
|
||||
return {
|
||||
"last_interaction": instance.last_interaction.isoformat()
|
||||
if instance.last_interaction
|
||||
else None,
|
||||
"dirty": instance._dirty,
|
||||
"last_save_time": instance._last_save_time,
|
||||
"file_exists": os.path.exists(instance.storage_path),
|
||||
}
|
||||
|
||||
|
||||
def file_obs(path):
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
with open(path) as f:
|
||||
raw = f.read()
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except ValueError: # corrupt content — deterministic sentinel, both pins
|
||||
return {"__unparseable__": raw}
|
||||
|
||||
|
||||
class Driver:
|
||||
def __init__(self, workdir):
|
||||
self.workdir = workdir
|
||||
shutil.rmtree(workdir, ignore_errors=True)
|
||||
os.makedirs(workdir, exist_ok=True)
|
||||
self.instance = None
|
||||
self.n = 0
|
||||
|
||||
def step(self, op):
|
||||
kind = op["op"]
|
||||
set_clock(op)
|
||||
if kind == "new":
|
||||
self.n += 1
|
||||
path = os.path.join(self.workdir, f"sto_{self.n}.json")
|
||||
seed = op.get("seed_file")
|
||||
if seed is None:
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
else:
|
||||
with open(path, "w") as f:
|
||||
f.write(seed)
|
||||
self.instance = ta.TimeAwareness(storage_path=path)
|
||||
return {"result": None, "state": state_obs(self.instance)}
|
||||
if kind == "seed_last":
|
||||
li = op.get("last_interaction")
|
||||
self.instance.last_interaction = (
|
||||
datetime.fromisoformat(li) if li is not None else None
|
||||
)
|
||||
return {"result": None, "state": state_obs(self.instance)}
|
||||
if kind == "ctx":
|
||||
update = op.get("update", True)
|
||||
prev_ls = self.instance._last_save_time
|
||||
try:
|
||||
r = self.instance.get_context(update_interaction=update).to_dict()
|
||||
except Exception as e: # noqa: BLE001 — the oracle raising IS the truth
|
||||
return {
|
||||
"result": {"error": type(e).__name__},
|
||||
"state": state_obs(self.instance),
|
||||
}
|
||||
ob = {"result": r, "state": state_obs(self.instance)}
|
||||
# exact _save_if_dirty decision: fired → the file obs carries it
|
||||
if update and (op["mono"] - prev_ls >= ta.TimeAwareness._SAVE_DEBOUNCE_SECS):
|
||||
ob["file"] = file_obs(self.instance.storage_path)
|
||||
return ob
|
||||
if kind == "status":
|
||||
try:
|
||||
r = self.instance.get_status()
|
||||
except Exception as e: # noqa: BLE001 — naive last_interaction raises; that IS the truth
|
||||
r = {"error": type(e).__name__}
|
||||
return {
|
||||
"result": r,
|
||||
"state": state_obs(self.instance),
|
||||
}
|
||||
if kind == "parse":
|
||||
return {
|
||||
"result": self.instance.parse_temporal_reference(op["text"]),
|
||||
"state": state_obs(self.instance),
|
||||
}
|
||||
if kind == "flush":
|
||||
self.instance.flush()
|
||||
return {
|
||||
"result": None,
|
||||
"state": state_obs(self.instance),
|
||||
"file": file_obs(self.instance.storage_path),
|
||||
}
|
||||
if kind == "mark_dirty":
|
||||
self.instance._mark_dirty()
|
||||
return {"result": None, "state": state_obs(self.instance)}
|
||||
if kind == "save_if_dirty":
|
||||
self.instance._save_if_dirty()
|
||||
return {
|
||||
"result": None,
|
||||
"state": state_obs(self.instance),
|
||||
"file": file_obs(self.instance.storage_path),
|
||||
}
|
||||
if kind == "mod_ctx" or kind == "mod_status" or kind == "mod_parse":
|
||||
seed = op.get("seed_file")
|
||||
if seed is None:
|
||||
if os.path.exists(MEM_FILE):
|
||||
os.remove(MEM_FILE)
|
||||
else:
|
||||
with open(MEM_FILE, "w") as f:
|
||||
f.write(seed)
|
||||
ta._time_awareness = None
|
||||
if kind == "mod_ctx":
|
||||
r = ta.get_context().to_dict()
|
||||
elif kind == "mod_status":
|
||||
r = ta.get_status()
|
||||
else:
|
||||
r = ta.parse_temporal_reference(op["text"])
|
||||
inst = ta._time_awareness
|
||||
ob = {"result": r, "state": state_obs(inst)}
|
||||
if kind == "mod_ctx":
|
||||
ob["file"] = file_obs(MEM_FILE)
|
||||
return ob
|
||||
if kind == "cli":
|
||||
import contextlib
|
||||
import io
|
||||
|
||||
ta._time_awareness = self.instance
|
||||
buf = io.StringIO()
|
||||
old_argv = sys.argv
|
||||
sys.argv = ["time_awareness.py"] + op.get("argv", [])
|
||||
try:
|
||||
with contextlib.redirect_stdout(buf):
|
||||
ta.main()
|
||||
finally:
|
||||
sys.argv = old_argv
|
||||
return {
|
||||
"result": buf.getvalue(),
|
||||
"state": state_obs(self.instance),
|
||||
"file": file_obs(self.instance.storage_path),
|
||||
}
|
||||
raise ValueError(f"unknown op {kind}")
|
||||
|
||||
def run(self, ops):
|
||||
return [self.step(op) for op in ops]
|
||||
|
||||
|
||||
def ctx_case(now_us, update, seed=None, hour=None):
|
||||
ops = [{"op": "new", "mono": 0.0}]
|
||||
if seed is not None:
|
||||
ops[0]["seed_file"] = seed
|
||||
ops.append({"op": "ctx", "update": update, "now_us": now_us, "mono": 0.0})
|
||||
return ops
|
||||
|
||||
|
||||
def scenarios():
|
||||
out = []
|
||||
|
||||
# S1 — time_of_day × circadian grid, weekend (2026-10-11 = Sunday) + weekday
|
||||
ops = []
|
||||
for h, m in [(4, 0), (5, 0), (6, 0), (8, 30), (9, 0), (11, 59), (12, 0),
|
||||
(16, 59), (17, 0), (18, 0), (20, 59), (21, 0), (22, 0), (23, 59)]:
|
||||
ops += [
|
||||
{"op": "new", "mono": 0.0},
|
||||
{"op": "ctx", "update": False,
|
||||
"now_us": us_of(datetime(2026, 10, 11, h, m, 0, 0, tzinfo=timezone.utc)),
|
||||
"mono": 0.0},
|
||||
]
|
||||
for h in (10, 13):
|
||||
ops += [
|
||||
{"op": "new", "mono": 0.0},
|
||||
{"op": "ctx", "update": False,
|
||||
"now_us": us_of(datetime(2026, 10, 13, h, 15, 0, 0, tzinfo=timezone.utc)),
|
||||
"mono": 0.0},
|
||||
]
|
||||
out.append({"name": "tod_circadian_grid", "ops": ops})
|
||||
|
||||
# S2 — urgency branches (update=False, seeded last_interaction)
|
||||
ops = []
|
||||
offsets_h = [25.0, 24.0, 4.0 + 1.0 / 3600.0, 4.0, 2.0, 0.1, 5.0 / 60.0, 0.0, -1.0]
|
||||
for i, oh in enumerate(offsets_h):
|
||||
li_us = BASE_US - int(oh * HOUR_US)
|
||||
seed = json.dumps(
|
||||
{"last_interaction": iso_of(li_us)}, indent=2
|
||||
) if i % 2 == 0 else None
|
||||
ops += [{"op": "new", "mono": 0.0}]
|
||||
if seed is None:
|
||||
ops += [{"op": "seed_last", "last_interaction": iso_of(li_us), "mono": 0.0}]
|
||||
ops += [{"op": "ctx", "update": False, "now_us": BASE_US, "mono": 0.0}]
|
||||
out.append({"name": "urgency_branches", "ops": ops})
|
||||
|
||||
# S3 — parse_temporal_reference: every pattern + boundaries
|
||||
texts = [
|
||||
"yesterday", "today", "tomorrow", "last week", "next week",
|
||||
"last month", "next month", "this morning", "this afternoon", "tonight",
|
||||
"no temporal refs here", "YESTERDAY we said TODAY", "yesterdays news today",
|
||||
"see you tomorrow.", "last week", "last\tweek", "lastweek",
|
||||
"xlast weeky", "café yesterday", "yesterdayé",
|
||||
"the last week of tomorrow", "next month and last month",
|
||||
"Monday morning blues this morning", "tonight, tomorrow",
|
||||
]
|
||||
ops = [{"op": "new", "mono": 0.0}] + [
|
||||
{"op": "parse", "text": t, "now_us": BASE_US, "mono": 0.0} for t in texts
|
||||
]
|
||||
out.append({"name": "parse_references", "ops": ops})
|
||||
|
||||
# S4 — save/debounce policy across a call sequence
|
||||
out.append({
|
||||
"name": "save_policy",
|
||||
"ops": [
|
||||
{"op": "new", "mono": 0.0},
|
||||
{"op": "ctx", "update": True, "now_us": BASE_US, "mono": 0.0},
|
||||
{"op": "ctx", "update": False, "now_us": BASE_US, "mono": 10.0},
|
||||
{"op": "mark_dirty", "mono": 10.0},
|
||||
{"op": "save_if_dirty", "mono": 20.0},
|
||||
{"op": "save_if_dirty", "mono": 40.0},
|
||||
{"op": "ctx", "update": True, "now_us": BASE_US + HOUR_US, "mono": 45.0},
|
||||
{"op": "flush", "mono": 45.0},
|
||||
{"op": "flush", "mono": 45.0},
|
||||
{"op": "status", "now_us": BASE_US + HOUR_US, "mono": 45.0},
|
||||
],
|
||||
})
|
||||
|
||||
# S5 — _load file-seed matrix
|
||||
seeds = [
|
||||
'{"last_interaction": "2026-10-10T10:00:00.000000+00:00"}',
|
||||
'{"last_interaction": "2026-10-10T10:00:00+00:00"}',
|
||||
'{"last_interaction": null}',
|
||||
"{}",
|
||||
"corrupt{",
|
||||
"[1, 2]",
|
||||
'{"last_interaction": "garbage"}',
|
||||
'{"last_interaction": "2026-10-10T10:00:00"}',
|
||||
'{"last_interaction": "2026-10-10T05:00:00-05:00"}',
|
||||
'{"last_interaction": "2026-10-10T10:00:00Z"}',
|
||||
]
|
||||
ops = []
|
||||
for i, s in enumerate(seeds):
|
||||
ops.append({"op": "new", "mono": 0.0, "seed_file": s})
|
||||
if i in (0, 7, 8):
|
||||
ops.append({"op": "ctx", "update": False, "now_us": BASE_US, "mono": 0.0})
|
||||
out.append({"name": "load_matrix", "ops": ops})
|
||||
|
||||
# S6 — update=True sequence: debounce fire across calls, status, flush, parse
|
||||
out.append({
|
||||
"name": "update_sequence",
|
||||
"ops": [
|
||||
{"op": "new", "mono": 0.0},
|
||||
{"op": "ctx", "update": True, "now_us": BASE_US, "mono": 0.0},
|
||||
{"op": "ctx", "update": True, "now_us": BASE_US + HOUR_US, "mono": 40.0},
|
||||
{"op": "status", "now_us": BASE_US + HOUR_US, "mono": 40.0},
|
||||
{"op": "flush", "mono": 40.0},
|
||||
{"op": "parse", "text": "yesterday", "now_us": BASE_US + HOUR_US, "mono": 40.0},
|
||||
{"op": "seed_last", "last_interaction": None, "mono": 40.0},
|
||||
{"op": "ctx", "update": True, "now_us": BASE_US + HOUR_US, "mono": 90.0},
|
||||
],
|
||||
})
|
||||
|
||||
# S7 — module singleton + CLI (facade-level; battery replays the kernel ops)
|
||||
out.append({
|
||||
"name": "module_and_cli",
|
||||
"ops": [
|
||||
{"op": "mod_ctx", "now_us": BASE_US, "mono": 0.0},
|
||||
{"op": "mod_status", "now_us": BASE_US, "mono": 0.0},
|
||||
{"op": "mod_parse", "text": "next week", "now_us": BASE_US, "mono": 0.0},
|
||||
{"op": "mod_ctx", "now_us": BASE_US, "mono": 0.0,
|
||||
"seed_file": '{"last_interaction": "2026-10-10T10:00:00+00:00"}'},
|
||||
{"op": "new", "mono": 0.0},
|
||||
{"op": "cli", "argv": ["status"], "now_us": BASE_US, "mono": 0.0},
|
||||
{"op": "cli", "argv": ["context"], "now_us": BASE_US, "mono": 0.0},
|
||||
{"op": "cli", "argv": ["bogus"], "now_us": BASE_US, "mono": 0.0},
|
||||
{"op": "cli", "argv": [], "now_us": BASE_US, "mono": 0.0},
|
||||
],
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
shutil.rmtree("/tmp/ta_truth_memory", ignore_errors=True)
|
||||
os.makedirs("/tmp/ta_truth_memory", exist_ok=True)
|
||||
scenarios_doc = scenarios()
|
||||
driver = Driver("/tmp/ta_truth_work")
|
||||
for s in scenarios_doc:
|
||||
s["obs"] = [driver.step(op) for op in s["ops"]]
|
||||
doc = {
|
||||
"module": "src/rune/time_awareness.py",
|
||||
"pinned": "RUNE_TA_CORE=python (poisoned-truth law); clock pinned via fake datetime.now + monotonic",
|
||||
"scenarios": scenarios_doc,
|
||||
}
|
||||
out_path = os.path.join(_ROOT, "src", "rust", "time_awareness", "src", "frozen_ta_truth.json")
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
with open(out_path, "w") as f:
|
||||
json.dump(doc, f, indent=1)
|
||||
f.write("\n")
|
||||
n_obs = sum(len(s["obs"]) for s in scenarios_doc)
|
||||
print(f"frozen {n_obs} observations across {len(scenarios_doc)} scenarios → {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ctypes loader for the Rust time_awareness core (dichotomy wave,
|
||||
#2479 tranche). Mirrors to_loader.py's contract:
|
||||
|
||||
FFI contract (doc-envelope law):
|
||||
* ta_run({op, state, args, now_us, now_mono}) ->
|
||||
{"ok": true, "value": ..., "state": {...mutated...},
|
||||
"save": bool, "snapshot": {...}}
|
||||
|
||||
The kernel is PURE — no clock, no filesystem. The caller supplies the
|
||||
state (last_interaction as isoformat text-or-null / dirty /
|
||||
last_save_time) and the clock (now_us = UTC epoch microseconds,
|
||||
now_mono = monotonic reading). `save: true` means the oracle would have
|
||||
written the file mid-call; `snapshot` is the trigger-time file content —
|
||||
the facade performs the write. All I/O, the singleton and the CLI stay
|
||||
in Python.
|
||||
|
||||
Override the .so with TA_RUST_LIB (a FILE path).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[2]
|
||||
_CRATE_DIR = _REPO / "src" / "rust" / "time_awareness"
|
||||
_LIB_PATH = Path(os.environ.get("TA_RUST_LIB", _CRATE_DIR / "target" / "release" / "libtime_awareness.so"))
|
||||
|
||||
_lib = None
|
||||
|
||||
|
||||
class TimeAwarenessCoreUnavailable(FileNotFoundError):
|
||||
"""The Rust time_awareness core is not built. Callers fall back to Python."""
|
||||
|
||||
|
||||
def available() -> bool:
|
||||
return _LIB_PATH.is_file()
|
||||
|
||||
|
||||
def _load() -> ctypes.CDLL:
|
||||
global _lib
|
||||
if _lib is not None:
|
||||
return _lib
|
||||
if not _LIB_PATH.is_file():
|
||||
raise TimeAwarenessCoreUnavailable(str(_LIB_PATH))
|
||||
lib = ctypes.CDLL(str(_LIB_PATH))
|
||||
lib.ta_run.argtypes = [ctypes.c_void_p]
|
||||
lib.ta_run.restype = ctypes.c_void_p
|
||||
_lib = lib
|
||||
return lib
|
||||
|
||||
|
||||
def _call(payload: dict) -> dict:
|
||||
lib = _load()
|
||||
data = json.dumps(payload).encode()
|
||||
buf = ctypes.create_string_buffer(data)
|
||||
ptr = lib.ta_run(ctypes.cast(buf, ctypes.c_void_p))
|
||||
if not ptr:
|
||||
raise RuntimeError("ta_run returned null")
|
||||
out = ctypes.string_at(ptr).decode()
|
||||
# free via any sibling free symbol already in-process (capability_scoring)
|
||||
try:
|
||||
scoring = ctypes.CDLL(str(
|
||||
_REPO / "src" / "rust" / "capability_scoring" / "target" / "release" / "libcapability_scoring.so"
|
||||
))
|
||||
scoring.cs_free_string.argtypes = [ctypes.c_void_p]
|
||||
scoring.cs_free_string(ptr)
|
||||
except OSError:
|
||||
pass # leak-on-fallback only if capability_scoring absent; bounded
|
||||
doc = json.loads(out)
|
||||
if not doc.get("ok"):
|
||||
raise RuntimeError(doc.get("err", "ta_run failed"))
|
||||
return doc
|
||||
|
||||
|
||||
def run_op(op: str, state: dict, args: dict | None = None, now_us: int = 0, now_mono: float = 0.0) -> dict:
|
||||
"""Run one kernel op. Returns the full envelope:
|
||||
{"value": ..., "state": {...mutated...}, "save": bool, "snapshot": ...}.
|
||||
"""
|
||||
payload = {"op": op, "state": state, "args": args or {},
|
||||
"now_us": now_us, "now_mono": now_mono}
|
||||
return _call(payload)
|
||||
+120
-4
@@ -30,6 +30,8 @@ if _SD not in sys.path:
|
||||
sys.path.insert(0, _SD)
|
||||
import memory_env
|
||||
|
||||
_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TemporalContext:
|
||||
@@ -106,13 +108,80 @@ class TimeAwareness:
|
||||
if now - self._last_save_time >= self._SAVE_DEBOUNCE_SECS:
|
||||
self._save()
|
||||
|
||||
# ── Rust kernel plumbing (dichotomy wave) ───────────────────────────
|
||||
# The kernel owns every temporal DECISION (time-of-day/circadian/
|
||||
# urgency branches, hours-since math, temporal-reference parsing,
|
||||
# dirty/debounce save policy); this facade keeps the I/O (_load/
|
||||
# _save), the clock and the CLI. `save: true` in the envelope means
|
||||
# the oracle wrote the file mid-call with the trigger-time snapshot —
|
||||
# we perform that write, then adopt the kernel's final state.
|
||||
|
||||
def _to_state(self) -> dict:
|
||||
"""Kernel transfer form; last_interaction as isoformat-or-null."""
|
||||
return {
|
||||
"last_interaction": self.last_interaction.isoformat() if self.last_interaction else None,
|
||||
"dirty": self._dirty,
|
||||
"last_save_time": self._last_save_time,
|
||||
}
|
||||
|
||||
def _apply_state(self, st: dict):
|
||||
self.last_interaction = (
|
||||
datetime.fromisoformat(st["last_interaction"])
|
||||
if st["last_interaction"] is not None
|
||||
else None
|
||||
)
|
||||
self._dirty = st["dirty"]
|
||||
self._last_save_time = st["last_save_time"]
|
||||
|
||||
def _write_snapshot(self, snap: dict):
|
||||
os.makedirs(os.path.dirname(self.storage_path), exist_ok=True)
|
||||
data = {"last_interaction": snap["last_interaction"]}
|
||||
tmp = self.storage_path + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
os.replace(tmp, self.storage_path)
|
||||
|
||||
def _run_kernel(self, op: str, args: dict, now_us: int, now_mono: float):
|
||||
"""Run one op through the Rust kernel; None → fall back to the
|
||||
Python body (kernel absent, loader failure — never a broken call)."""
|
||||
core = _ta_core()
|
||||
if core is None:
|
||||
return None
|
||||
try:
|
||||
doc = core.run_op(op, self._to_state(), args, now_us, now_mono)
|
||||
except Exception:
|
||||
return None
|
||||
if doc.get("save"):
|
||||
self._write_snapshot(doc["snapshot"])
|
||||
self._apply_state(doc["state"])
|
||||
return doc["value"]
|
||||
|
||||
def get_context(self, update_interaction: bool = True) -> TemporalContext:
|
||||
"""Get current temporal context.
|
||||
|
||||
Args:
|
||||
update_interaction: If True, update last_interaction timestamp.
|
||||
Set to False for read-only status checks.
|
||||
"""
|
||||
Delegates the whole temporal decision to the Rust core when built."""
|
||||
now = datetime.now(timezone.utc)
|
||||
delegated = self._run_kernel(
|
||||
"get_context",
|
||||
{"update_interaction": update_interaction},
|
||||
(now - _EPOCH) // timedelta(microseconds=1),
|
||||
_time.monotonic(),
|
||||
)
|
||||
if delegated is not None:
|
||||
return TemporalContext(
|
||||
current_time=delegated["current_time"],
|
||||
current_date=delegated["current_date"],
|
||||
day_of_week=delegated["day_of_week"],
|
||||
time_of_day=delegated["time_of_day"],
|
||||
is_weekend=delegated["is_weekend"],
|
||||
hours_since_last_interaction=delegated["hours_since_last_interaction"],
|
||||
urgency_modifier=delegated["urgency_modifier"],
|
||||
circadian_phase=delegated["circadian_phase"],
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Calculate hours since last interaction
|
||||
@@ -174,7 +243,18 @@ class TimeAwareness:
|
||||
|
||||
def parse_temporal_reference(self, text: str) -> Dict[str, Any]:
|
||||
"""Parse temporal references in text (yesterday, last week, etc.).
|
||||
Uses word boundary matching to avoid false positives."""
|
||||
Uses word boundary matching to avoid false positives.
|
||||
Delegates the matching decision to the Rust core when built."""
|
||||
now = datetime.now(timezone.utc)
|
||||
delegated = self._run_kernel(
|
||||
"parse_temporal_reference",
|
||||
{"text": text},
|
||||
(now - _EPOCH) // timedelta(microseconds=1),
|
||||
_time.monotonic(),
|
||||
)
|
||||
if delegated is not None:
|
||||
return delegated
|
||||
|
||||
text_lower = text.lower()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
@@ -206,7 +286,12 @@ class TimeAwareness:
|
||||
return {"reference": None}
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""Get time awareness status (read-only, no side effects)."""
|
||||
"""Get time awareness status (read-only, no side effects).
|
||||
Delegates to the Rust core when built."""
|
||||
delegated = self._run_kernel("get_status", {}, *_clock_args())
|
||||
if delegated is not None:
|
||||
return delegated
|
||||
|
||||
context = self.get_context(update_interaction=False)
|
||||
return {
|
||||
"current_time": context.current_time,
|
||||
@@ -219,11 +304,42 @@ class TimeAwareness:
|
||||
}
|
||||
|
||||
def flush(self):
|
||||
"""Force-save any pending changes to disk."""
|
||||
"""Force-save any pending changes to disk.
|
||||
The save DECISION delegates to the Rust core when built (the
|
||||
facade performs the write via the snapshot)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
delegated = self._run_kernel(
|
||||
"flush", {},
|
||||
(now - _EPOCH) // timedelta(microseconds=1),
|
||||
_time.monotonic(),
|
||||
)
|
||||
if delegated is not None:
|
||||
return
|
||||
if self._dirty:
|
||||
self._save()
|
||||
|
||||
|
||||
def _clock_args():
|
||||
"""(now_us, now_mono) for kernel ops that need no computed `now`."""
|
||||
now = datetime.now(timezone.utc)
|
||||
return (now - _EPOCH) // timedelta(microseconds=1), _time.monotonic()
|
||||
|
||||
|
||||
def _ta_core():
|
||||
"""The Rust time_awareness core when available (strangler law).
|
||||
RUNE_TA_CORE=python pins the fallback."""
|
||||
if os.environ.get("RUNE_TA_CORE", "auto") == "python":
|
||||
return None
|
||||
try:
|
||||
from rune import ta_loader
|
||||
except Exception: # noqa: BLE001 — any loader failure means fallback, never a broken call
|
||||
return None
|
||||
try:
|
||||
return ta_loader if ta_loader.available() else None
|
||||
except Exception: # noqa: BLE001 — availability probe must never crash the fallback
|
||||
return None
|
||||
|
||||
|
||||
# Global time awareness
|
||||
_time_awareness = None
|
||||
|
||||
|
||||
Generated
+98
@@ -2,6 +2,104 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01016da373cd8f7ef12624f796309f5c31ba8d646dd08856c02cd741d823c622"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time_awareness"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -2,6 +2,10 @@
|
||||
name = "time_awareness"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "FULL parity port of src/rune/time_awareness.py temporal core (dichotomy wave, #2479 tranche); decision logic in the kernel, file I/O and clock stay Python"
|
||||
|
||||
[lib]
|
||||
crate-type = ["rlib", "cdylib", "staticlib"]
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Branch battery — replays the FROZEN ORACLE truth VERBATIM
|
||||
//! (frozen_ta_truth.json, generated from the live python oracle with
|
||||
//! RUNE_TA_CORE=python pinned and the clock faked to fixed instants).
|
||||
//! Each scenario's op script is driven through the kernel exactly as the
|
||||
//! facade drives it (state in → state out, snapshot on save), and every
|
||||
//! observation (result + state + file) must equal the frozen oracle
|
||||
//! byte-for-byte after JSON parse. Because the clock is PINNED, nothing
|
||||
//! is masked — current_time, dates, hours_since, last_interaction and
|
||||
//! last_save_time are all frozen and compared verbatim.
|
||||
//!
|
||||
//! Facade-level ops are skipped here (live-parity covered): `cli` (python
|
||||
//! main()) and `mod_*` (module-singleton management). A kernel envelope
|
||||
//! error on get_context/get_status is the oracle's TypeError (naive
|
||||
//! last_interaction) raised through the facade fallback — modelled below.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::{load_from_str, run_op, state_to_value, State};
|
||||
|
||||
const TRUTH: &str = include_str!("frozen_ta_truth.json");
|
||||
|
||||
enum FileModel {
|
||||
Absent,
|
||||
/// Exists on disk but not the oracle's document shape (corrupt JSON,
|
||||
/// a JSON array…) — the oracle's blanket except treats it as no data.
|
||||
/// The truth never OBSERVES its content pre-save (a save overwrites).
|
||||
Corrupt,
|
||||
Doc(Value),
|
||||
}
|
||||
|
||||
fn state_obs(st: &State, file: &FileModel) -> Value {
|
||||
let mut v = state_to_value(st);
|
||||
v["file_exists"] = json!(!matches!(file, FileModel::Absent));
|
||||
v
|
||||
}
|
||||
|
||||
fn file_obs(file: &FileModel) -> Value {
|
||||
match file {
|
||||
FileModel::Absent => Value::Null,
|
||||
FileModel::Corrupt => panic!("corrupt file observed pre-save — the oracle would crash reading it"),
|
||||
FileModel::Doc(d) => d.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive one battery op through the kernel the way the facade would.
|
||||
/// Returns the full observation (result + state [+ file]).
|
||||
fn step(st: &mut State, op: &Value, file: &mut FileModel) -> Value {
|
||||
let kind = op["op"].as_str().unwrap_or("").to_string();
|
||||
let now_us = op.get("now_us").and_then(Value::as_i64).unwrap_or(0);
|
||||
let now_mono = op.get("mono").and_then(Value::as_f64).unwrap_or(0.0);
|
||||
let mut snap: Option<Value> = None;
|
||||
let result = match kind.as_str() {
|
||||
"new" => {
|
||||
match op.get("seed_file").and_then(Value::as_str) {
|
||||
Some(s) => {
|
||||
*st = load_from_str(s);
|
||||
*file = match serde_json::from_str::<Value>(s) {
|
||||
Ok(v) if v.is_object() => FileModel::Doc(v),
|
||||
Ok(_) => FileModel::Corrupt, // parses, but not a document
|
||||
Err(_) => FileModel::Corrupt,
|
||||
};
|
||||
}
|
||||
None => {
|
||||
*st = State::default();
|
||||
*file = FileModel::Absent;
|
||||
}
|
||||
}
|
||||
// the oracle's __init__ stamps the monotonic clock
|
||||
st.last_save_time = now_mono;
|
||||
Value::Null
|
||||
}
|
||||
"seed_last" => {
|
||||
st.last_interaction = op
|
||||
.get("last_interaction")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
Value::Null
|
||||
}
|
||||
"ctx" => {
|
||||
let args = json!({"update_interaction": op.get("update").and_then(Value::as_bool).unwrap_or(true)});
|
||||
match run_op(st, "get_context", &args, now_us, now_mono, &mut snap) {
|
||||
Ok(v) => v,
|
||||
// naive last_interaction: the facade falls back to python,
|
||||
// which raises TypeError — the oracle's observable truth
|
||||
Err(_) => json!({"error": "TypeError"}),
|
||||
}
|
||||
}
|
||||
"status" => match run_op(st, "get_status", &json!({}), now_us, now_mono, &mut snap) {
|
||||
Ok(v) => v,
|
||||
// naive last_interaction: the facade falls back to python,
|
||||
// which raises TypeError — the oracle's observable truth
|
||||
Err(_) => json!({"error": "TypeError"}),
|
||||
},
|
||||
"parse" => {
|
||||
let args = json!({"text": op["text"].as_str().unwrap_or("")});
|
||||
run_op(st, "parse_temporal_reference", &args, now_us, now_mono, &mut snap)
|
||||
.expect("parse op failed")
|
||||
}
|
||||
"flush" => run_op(st, "flush", &json!({}), now_us, now_mono, &mut snap)
|
||||
.expect("flush op failed"),
|
||||
"mark_dirty" => run_op(st, "mark_dirty", &json!({}), now_us, now_mono, &mut snap)
|
||||
.expect("mark_dirty op failed"),
|
||||
"save_if_dirty" => run_op(st, "save_if_dirty", &json!({}), now_us, now_mono, &mut snap)
|
||||
.expect("save_if_dirty op failed"),
|
||||
other => panic!("battery must not see facade-level op {other}"),
|
||||
};
|
||||
if let Some(s) = &snap {
|
||||
*file = FileModel::Doc(s.clone());
|
||||
}
|
||||
let mut ob = json!({"result": result, "state": state_obs(st, file)});
|
||||
// the truth generator attaches the file obs exactly when the save
|
||||
// fired (ctx mirrors _save_if_dirty's decision) — flush/save_if_dirty
|
||||
// always carry it, matching the driver
|
||||
if snap.is_some() || matches!(kind.as_str(), "flush" | "save_if_dirty") {
|
||||
ob["file"] = file_obs(file);
|
||||
}
|
||||
ob
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_frozen_truth_verbatim() {
|
||||
let doc: Value = serde_json::from_str(TRUTH).expect("frozen truth must parse");
|
||||
let scenarios = doc["scenarios"].as_array().expect("scenarios array");
|
||||
let mut checked = 0usize;
|
||||
for s in scenarios {
|
||||
let name = s["name"].as_str().unwrap_or("?");
|
||||
let ops = s["ops"].as_array().expect("ops");
|
||||
let obs = s["obs"].as_array().expect("obs");
|
||||
assert_eq!(ops.len(), obs.len(), "{name}: ops/obs length mismatch");
|
||||
let mut st = State::default();
|
||||
let mut file = FileModel::Absent;
|
||||
for (i, (op, want)) in ops.iter().zip(obs.iter()).enumerate() {
|
||||
let kind = op["op"].as_str().unwrap_or("").to_string();
|
||||
if kind == "cli" || kind.starts_with("mod_") {
|
||||
continue; // facade-level (python main()/singleton); live-parity covered
|
||||
}
|
||||
let got = step(&mut st, op, &mut file);
|
||||
assert_eq!(&got, want, "{name} op#{i} ({kind}) diverged");
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
assert!(checked >= 60, "battery shrank: {checked} observations replayed");
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,99 +1,501 @@
|
||||
//! time_awareness — Rune's sense of time (W6).
|
||||
//! PARITY port of src/rune/time_awareness.py.
|
||||
//! time_awareness — Rune's sense of time.
|
||||
//!
|
||||
//! FULL parity port of src/rune/time_awareness.py (dichotomy wave, #2479
|
||||
//! tranche). The kernel is PURE: it owns every temporal decision (time of
|
||||
//! day, circadian phase, weekend, hours-since math, urgency modifier,
|
||||
//! temporal-reference parsing, dirty/debounce save policy) while the
|
||||
//! Python facade keeps the I/O (`_load`/`_save`), the clock, the singleton
|
||||
//! and the CLI — split at the pure/IO boundary (T8 law).
|
||||
//!
|
||||
//! FFI (doc-envelope law): `ta_run({op, state, args, now_us, now_mono})`
|
||||
//! → {"ok": true, "value": ..., "state": {...mutated...}, "save": bool,
|
||||
//! "snapshot": {...file content to write...}}.
|
||||
//! `now_us` is UTC epoch microseconds (the facade reads the clock and
|
||||
//! hands it in); `now_mono` is the monotonic reading. `save: true` means
|
||||
//! the oracle would have written the file mid-call; `snapshot` is the
|
||||
//! trigger-time content ({"last_interaction": iso|null}) — the facade
|
||||
//! performs the write.
|
||||
//!
|
||||
//! Float parity: Python computes hours_since as
|
||||
//! `((days*86400 + secs) * 10**6 + micros) / 10**6 / 3600` — the kernel
|
||||
//! does `(delta_us as f64) / 1e6 / 3600.0`, the identical op sequence on
|
||||
//! exact-integer deltas, so results are bit-identical.
|
||||
//!
|
||||
//! ISO handling: the state carries `last_interaction` exactly as the
|
||||
//! facade holds it (datetime.isoformat() output: optional fraction,
|
||||
//! ±HH:MM offset or naive). A NAIVE last_interaction parses fine in
|
||||
//! Python's `_load` but makes `now - naive` raise TypeError — the kernel
|
||||
//! surfaces that as an envelope error, and the facade's fallback path
|
||||
//! raises the very same TypeError. Byte-faithful, including the quirk.
|
||||
|
||||
/// Temporal context for an interaction
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TemporalContext {
|
||||
pub current_time: String,
|
||||
pub current_date: String,
|
||||
pub day_of_week: String,
|
||||
pub time_of_day: String,
|
||||
pub is_weekend: bool,
|
||||
pub hours_since_last_interaction: f64,
|
||||
pub urgency_modifier: f64,
|
||||
pub circadian_phase: String,
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const SAVE_DEBOUNCE_SECS: f64 = 30.0;
|
||||
|
||||
const US_PER_DAY: i64 = 86_400_000_000;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct State {
|
||||
/// datetime.isoformat() of the facade's last_interaction (or None)
|
||||
last_interaction: Option<String>,
|
||||
dirty: bool,
|
||||
last_save_time: f64,
|
||||
}
|
||||
|
||||
pub fn get_temporal_context(last_interaction: Option<u64>) -> TemporalContext {
|
||||
let now = std::time::SystemTime::now();
|
||||
let secs = now.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
|
||||
// ── state (de)serialization ─────────────────────────────────────────────
|
||||
|
||||
// Day of week: 0=Thursday (epoch), 1=Friday, ...
|
||||
let days_since_epoch = secs / 86400;
|
||||
let dow_idx = ((days_since_epoch + 4) % 7) as usize; // +4 because epoch is Thursday
|
||||
let day_names = [
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
"Sunday",
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
];
|
||||
let day_of_week = day_names[dow_idx].to_string();
|
||||
|
||||
let is_weekend = dow_idx == 2 || dow_idx == 3; // Saturday or Sunday
|
||||
|
||||
// Hour of day (UTC)
|
||||
let hour = (secs % 86400) / 3600;
|
||||
let time_of_day = match hour {
|
||||
6..=11 => "morning",
|
||||
12..=17 => "afternoon",
|
||||
18..=22 => "evening",
|
||||
_ => "night",
|
||||
}
|
||||
.to_string();
|
||||
|
||||
let circadian_phase = match hour {
|
||||
7..=21 => "active",
|
||||
22..=23 => "winding_down",
|
||||
0..=4 => "sleeping",
|
||||
5..=6 => "waking",
|
||||
_ => "active",
|
||||
}
|
||||
.to_string();
|
||||
|
||||
let hours_since = match last_interaction {
|
||||
Some(last) => (secs as f64 - last as f64) / 3600.0,
|
||||
None => 0.0,
|
||||
};
|
||||
|
||||
let urgency_modifier = if hours_since > 24.0 {
|
||||
1.2
|
||||
} else if hours_since > 8.0 {
|
||||
1.0
|
||||
} else {
|
||||
0.8
|
||||
};
|
||||
|
||||
TemporalContext {
|
||||
current_time: format!("{}:{:02}:{:02}", hour, (secs % 3600) / 60, secs % 60),
|
||||
current_date: format!("epoch day {}", days_since_epoch),
|
||||
day_of_week,
|
||||
time_of_day,
|
||||
is_weekend,
|
||||
hours_since_last_interaction: hours_since,
|
||||
urgency_modifier,
|
||||
circadian_phase,
|
||||
}
|
||||
fn parse_state(v: &Value) -> Result<State, String> {
|
||||
let o = v.as_object().ok_or("state must be an object")?;
|
||||
Ok(State {
|
||||
last_interaction: o
|
||||
.get("last_interaction")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
dirty: o.get("dirty").and_then(Value::as_bool).unwrap_or(false),
|
||||
last_save_time: o
|
||||
.get("last_save_time")
|
||||
.and_then(Value::as_f64)
|
||||
.unwrap_or(0.0),
|
||||
})
|
||||
}
|
||||
|
||||
fn state_to_value(st: &State) -> Value {
|
||||
json!({
|
||||
"last_interaction": st.last_interaction.clone(),
|
||||
"dirty": st.dirty,
|
||||
"last_save_time": st.last_save_time,
|
||||
})
|
||||
}
|
||||
|
||||
/// Battery-only: the oracle's `_load` semantics over the FILE format —
|
||||
/// corrupt JSON, a non-object document, a missing/null key or an ISO
|
||||
/// string Python's fromisoformat rejects all discard the timestamp (the
|
||||
/// blanket `except` recovery). A valid string is canonicalized through
|
||||
/// parse+emit exactly like `datetime.fromisoformat(...).isoformat()`
|
||||
/// (fraction dropped when zero, Z → +00:00). The production facade keeps
|
||||
/// the real `_load` (I/O side).
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_temporal_context() {
|
||||
let ctx = get_temporal_context(None);
|
||||
assert!(!ctx.day_of_week.is_empty());
|
||||
assert!(ctx.urgency_modifier >= 0.8);
|
||||
assert!(!ctx.circadian_phase.is_empty());
|
||||
fn load_from_str(s: &str) -> State {
|
||||
let mut st = State::default();
|
||||
let v = match serde_json::from_str::<Value>(s) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return st,
|
||||
};
|
||||
let Some(o) = v.as_object() else {
|
||||
return st; // .get() on a non-dict raises → blanket except
|
||||
};
|
||||
if let Some(Value::String(s)) = o.get("last_interaction") {
|
||||
if let Some((us, off)) = parse_iso(s) {
|
||||
st.last_interaction = Some(emit_iso(us, off));
|
||||
} // parse failure = fromisoformat raising → blanket except
|
||||
}
|
||||
st
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_weekend_detection() {
|
||||
let ctx = get_temporal_context(None);
|
||||
// Just verify it's a bool — the actual day depends on current time
|
||||
let _ = ctx.is_weekend;
|
||||
// ── calendar math (proleptic Gregorian, Howard Hinnant's algorithms) ────
|
||||
|
||||
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||
let yoe = y - era * 400;
|
||||
let mp = if m > 2 { m - 3 } else { m + 9 } as i64;
|
||||
let doy = (153 * mp + 2) / 5 + d as i64 - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146097 + doe - 719468
|
||||
}
|
||||
|
||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719468;
|
||||
let era = if z >= 0 { z } else { z - 146096 } / 146097;
|
||||
let doe = z - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 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 as u32, d as u32)
|
||||
}
|
||||
|
||||
/// Python weekday(): Monday=0 … Sunday=6.
|
||||
fn python_weekday(days: i64) -> u32 {
|
||||
(days + 3).rem_euclid(7) as u32
|
||||
}
|
||||
|
||||
// ── ISO 8601 (the exact forms datetime.isoformat()/fromisoformat emit) ──
|
||||
|
||||
/// Parse "YYYY-MM-DDTHH:MM:SS[.ffffff][Z|±HH:MM]" → (epoch µs, offset
|
||||
/// seconds: None for naive). Accepts the ' ' separator and 1-6 fraction
|
||||
/// digits like fromisoformat; anything else → None (the blanket except).
|
||||
fn parse_iso(s: &str) -> Option<(i64, Option<i32>)> {
|
||||
let b = s.as_bytes();
|
||||
let num = |r: std::ops::Range<usize>| -> Option<i64> {
|
||||
let sl = s.get(r.clone())?;
|
||||
if sl.bytes().any(|c| !c.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
sl.parse::<i64>().ok()
|
||||
};
|
||||
if b.len() < 19 || b[4] != b'-' || b[7] != b'-' {
|
||||
return None;
|
||||
}
|
||||
let (sep, rest) = match b[10] {
|
||||
b'T' | b' ' => (10, 11),
|
||||
_ => return None,
|
||||
};
|
||||
let _ = sep;
|
||||
if b[13] != b':' || b[16] != b':' {
|
||||
return None;
|
||||
}
|
||||
let (y, mo, d) = (num(0..4)?, num(5..7)?, num(8..10)?);
|
||||
let (h, mi, se) = (num(rest..rest + 2)?, num(rest + 3..rest + 5)?, num(rest + 6..rest + 8)?);
|
||||
if !(1..=12).contains(&mo) || !(1..=31).contains(&d) || h > 23 || mi > 59 || se > 59 {
|
||||
return None;
|
||||
}
|
||||
let mut i = rest + 8;
|
||||
let mut frac_us = 0i64;
|
||||
if i < b.len() && b[i] == b'.' {
|
||||
i += 1;
|
||||
let start = i;
|
||||
while i < b.len() && b[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
let digits = i - start;
|
||||
if digits == 0 {
|
||||
return None;
|
||||
}
|
||||
// fromisoformat truncates beyond microseconds
|
||||
frac_us = num(start..i.min(start + 6))? * 10i64.pow(6 - (digits.min(6)) as u32);
|
||||
}
|
||||
let mut offset = None;
|
||||
if i < b.len() {
|
||||
match b[i] {
|
||||
b'Z' | b'z' => {
|
||||
offset = Some(0);
|
||||
i += 1;
|
||||
}
|
||||
b'+' | b'-' => {
|
||||
if b.len() != i + 6 || b[i + 3] != b':' {
|
||||
return None;
|
||||
}
|
||||
let (oh, om) = (num(i + 1..i + 3)?, num(i + 4..i + 6)?);
|
||||
if oh > 23 || om > 59 {
|
||||
return None;
|
||||
}
|
||||
let mag = oh * 3600 + om * 60;
|
||||
offset = Some(if b[i] == b'-' { -mag } else { mag } as i32);
|
||||
i += 6;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
if i != b.len() {
|
||||
return None;
|
||||
}
|
||||
let days = days_from_civil(y, mo as u32, d as u32);
|
||||
let mut us = days * US_PER_DAY + h * 3_600_000_000 + mi * 60_000_000 + se * 1_000_000 + frac_us;
|
||||
if let Some(off) = offset {
|
||||
us -= off as i64 * 1_000_000;
|
||||
}
|
||||
Some((us, offset))
|
||||
}
|
||||
|
||||
/// Inverse of parse: epoch µs + offset → datetime.isoformat() text
|
||||
/// (fraction only when non-zero, offset as ±HH:MM, naive has none).
|
||||
fn emit_iso(us: i64, offset: Option<i32>) -> String {
|
||||
let wall = us + offset.map_or(0, |o| o as i64) * 1_000_000;
|
||||
let days = wall.div_euclid(US_PER_DAY);
|
||||
let sod = wall.rem_euclid(US_PER_DAY);
|
||||
let secs = sod / 1_000_000;
|
||||
let frac = sod % 1_000_000;
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
let (h, mi, s) = (secs / 3600, (secs % 3600) / 60, secs % 60);
|
||||
let mut out = format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}");
|
||||
if frac != 0 {
|
||||
out.push_str(&format!(".{frac:06}"));
|
||||
}
|
||||
if let Some(off) = offset {
|
||||
let (sign, mag) = if off < 0 { ('-', -off) } else { ('+', off) };
|
||||
out.push_str(&format!("{sign}{:02}:{:02}", mag / 3600, (mag % 3600) / 60));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ── temporal core ───────────────────────────────────────────────────────
|
||||
|
||||
fn date_of(days: i64) -> String {
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}")
|
||||
}
|
||||
|
||||
fn hours_since(st: &State, now_us: i64) -> Result<f64, String> {
|
||||
match &st.last_interaction {
|
||||
None => Ok(0.0),
|
||||
Some(s) => match parse_iso(s) {
|
||||
// naive-aware subtraction: Python raises TypeError here
|
||||
Some((lu, Some(_))) => Ok(((now_us - lu) as f64) / 1_000_000.0 / 3600.0),
|
||||
Some((_lu, None)) => Err("naive last_interaction".into()),
|
||||
None => Err("unparseable last_interaction".into()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The context body (both get_context and get_status read it); the
|
||||
/// update+save side effect happens only when `update` is set.
|
||||
fn context_value(st: &mut State, update: bool, now_us: i64, now_mono: f64, snap: &mut Option<Value>) -> Result<Value, String> {
|
||||
let hours = hours_since(st, now_us)?;
|
||||
|
||||
if update {
|
||||
st.last_interaction = Some(emit_iso(now_us, Some(0)));
|
||||
st.dirty = true;
|
||||
if now_mono - st.last_save_time >= SAVE_DEBOUNCE_SECS {
|
||||
st.dirty = false;
|
||||
st.last_save_time = now_mono;
|
||||
*snap = Some(json!({"last_interaction": st.last_interaction.clone()}));
|
||||
}
|
||||
}
|
||||
|
||||
let days = now_us.div_euclid(US_PER_DAY);
|
||||
let secs = now_us.rem_euclid(US_PER_DAY) / 1_000_000;
|
||||
let hour = (secs / 3600) as u32;
|
||||
|
||||
let time_of_day = if (5..12).contains(&hour) {
|
||||
"morning"
|
||||
} else if (12..17).contains(&hour) {
|
||||
"afternoon"
|
||||
} else if (17..21).contains(&hour) {
|
||||
"evening"
|
||||
} else {
|
||||
"night"
|
||||
};
|
||||
|
||||
let circadian_phase = if !(6..22).contains(&hour) {
|
||||
"sleeping"
|
||||
} else if (6..9).contains(&hour) {
|
||||
"waking"
|
||||
} else if (9..18).contains(&hour) {
|
||||
"active"
|
||||
} else {
|
||||
"winding_down"
|
||||
};
|
||||
|
||||
let urgency_modifier = if hours > 24.0 {
|
||||
1.5
|
||||
} else if hours > 4.0 {
|
||||
1.2
|
||||
} else if hours < 0.1 {
|
||||
0.8
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
let weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
||||
let wd = python_weekday(days);
|
||||
|
||||
Ok(json!({
|
||||
"current_time": format!("{:02}:{:02}", secs / 3600, (secs % 3600) / 60),
|
||||
"current_date": date_of(days),
|
||||
"day_of_week": weekdays[wd as usize],
|
||||
"time_of_day": time_of_day,
|
||||
"is_weekend": wd >= 5,
|
||||
"hours_since_last_interaction": hours,
|
||||
"urgency_modifier": urgency_modifier,
|
||||
"circadian_phase": circadian_phase,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `parse_temporal_reference` — the oracle's pattern list, in priority
|
||||
/// order (first LIST entry that matches anywhere wins, not the earliest
|
||||
/// text position). Word boundaries follow Python re semantics: a word
|
||||
/// char is alphanumeric or '_', and \s is the Unicode whitespace class.
|
||||
fn is_word(c: char) -> bool {
|
||||
c.is_alphanumeric() || c == '_'
|
||||
}
|
||||
|
||||
fn word_at(text: &[char], pos: usize, word: &[char]) -> bool {
|
||||
if pos + word.len() > text.len() {
|
||||
return false;
|
||||
}
|
||||
let before_ok = pos == 0 || !is_word(text[pos - 1]);
|
||||
let after_ok = pos + word.len() == text.len() || !is_word(text[pos + word.len()]);
|
||||
before_ok && after_ok && text[pos..pos + word.len()] == *word
|
||||
}
|
||||
|
||||
/// `\b{w1}\s+{w2}\b` — w1 must start at a boundary, then a run of one or
|
||||
/// more whitespace chars, then w2 at a trailing boundary. The whitespace
|
||||
/// run is forced (w2 is not whitespace), so greedy matching is unique.
|
||||
fn pair_at(text: &[char], pos: usize, w1: &[char], w2: &[char]) -> bool {
|
||||
if pos + w1.len() > text.len() || text[pos..pos + w1.len()] != *w1 {
|
||||
return false;
|
||||
}
|
||||
if pos != 0 && is_word(text[pos - 1]) {
|
||||
return false; // no \b before w1
|
||||
}
|
||||
let mut i = pos + w1.len();
|
||||
let ws_start = i;
|
||||
while i < text.len() && text[i].is_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
if i == ws_start {
|
||||
return false; // \s+ needs at least one
|
||||
}
|
||||
word_at(text, i, w2)
|
||||
}
|
||||
|
||||
fn scan(text: &[char], pred: impl Fn(usize) -> bool) -> bool {
|
||||
(0..text.len()).any(pred)
|
||||
}
|
||||
|
||||
fn parse_temporal_reference(text: &str, now_us: i64) -> Value {
|
||||
let lower: Vec<char> = text.to_lowercase().chars().collect();
|
||||
let w = |s: &str| s.chars().collect::<Vec<char>>();
|
||||
let one = |word: &str| scan(&lower, |p| word_at(&lower, p, &w(word)));
|
||||
let two = |a: &str, b: &str| scan(&lower, |p| pair_at(&lower, p, &w(a), &w(b)));
|
||||
|
||||
let days = now_us.div_euclid(US_PER_DAY);
|
||||
let hit: Option<(&str, i64, Option<&str>)> = if one("yesterday") {
|
||||
Some(("yesterday", -1, None))
|
||||
} else if one("today") {
|
||||
Some(("today", 0, None))
|
||||
} else if one("tomorrow") {
|
||||
Some(("tomorrow", 1, None))
|
||||
} else if two("last", "week") {
|
||||
Some(("last week", -7, None))
|
||||
} else if two("next", "week") {
|
||||
Some(("next week", 7, None))
|
||||
} else if two("last", "month") {
|
||||
Some(("last month", -30, None))
|
||||
} else if two("next", "month") {
|
||||
Some(("next month", 30, None))
|
||||
} else if two("this", "morning") {
|
||||
Some(("this morning", 0, Some("morning")))
|
||||
} else if two("this", "afternoon") {
|
||||
Some(("this afternoon", 0, Some("afternoon")))
|
||||
} else if one("tonight") {
|
||||
Some(("tonight", 0, Some("night")))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match hit {
|
||||
None => json!({"reference": null}),
|
||||
Some((reference, delta, time)) => {
|
||||
let mut result = json!({
|
||||
"reference": reference,
|
||||
"date": date_of(days + delta),
|
||||
});
|
||||
if let Some(t) = time {
|
||||
result["time"] = json!(t);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── dispatch ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Runs one op against the state. Returns the op VALUE; `snap` receives
|
||||
/// the file content to persist when the oracle would have written mid-call.
|
||||
fn run_op(
|
||||
st: &mut State,
|
||||
op: &str,
|
||||
args: &Value,
|
||||
now_us: i64,
|
||||
now_mono: f64,
|
||||
snap: &mut Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
match op {
|
||||
"get_context" => {
|
||||
let update = args.get("update_interaction").and_then(Value::as_bool).unwrap_or(true);
|
||||
context_value(st, update, now_us, now_mono, snap)
|
||||
}
|
||||
"get_status" => {
|
||||
let mut v = context_value(st, false, now_us, now_mono, snap)?;
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.remove("urgency_modifier");
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
"parse_temporal_reference" => {
|
||||
let text = args.get("text").and_then(Value::as_str).unwrap_or("");
|
||||
Ok(parse_temporal_reference(text, now_us))
|
||||
}
|
||||
"mark_dirty" => {
|
||||
st.dirty = true;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"save_if_dirty" => {
|
||||
if st.dirty && now_mono - st.last_save_time >= SAVE_DEBOUNCE_SECS {
|
||||
st.dirty = false;
|
||||
st.last_save_time = now_mono;
|
||||
*snap = Some(json!({"last_interaction": st.last_interaction.clone()}));
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"flush" => {
|
||||
if st.dirty {
|
||||
st.dirty = false;
|
||||
st.last_save_time = now_mono;
|
||||
*snap = Some(json!({"last_interaction": st.last_interaction.clone()}));
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
other => Err(format!("unknown op: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ── FFI (doc-envelope law) ──────────────────────────────────────────────
|
||||
|
||||
use std::ffi::{CStr, CString};
|
||||
use std::os::raw::c_char;
|
||||
|
||||
fn read_arg(ptr: *const c_char) -> Result<Value, String> {
|
||||
if ptr.is_null() {
|
||||
return Err("null argument pointer".into());
|
||||
}
|
||||
let x = unsafe { CStr::from_ptr(ptr) }.to_str().map_err(|e| e.to_string())?;
|
||||
serde_json::from_str(x).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn envelope(r: Result<(Value, State, Option<Value>), String>) -> *mut c_char {
|
||||
let v = match r {
|
||||
Ok((value, st, snap)) => json!({
|
||||
"ok": true,
|
||||
"value": value,
|
||||
"state": state_to_value(&st),
|
||||
"save": snap.is_some(),
|
||||
"snapshot": snap.unwrap_or(Value::Null),
|
||||
}),
|
||||
Err(e) => json!({"ok": false, "err": e}),
|
||||
};
|
||||
CString::new(serde_json::to_string(&v).unwrap_or_default())
|
||||
.map(|c| c.into_raw())
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
/// ta_run({"op", "state", "args"?, "now_us"?, "now_mono"?})
|
||||
/// → {"ok": true, "value": ..., "state": {...mutated...}, "save": bool,
|
||||
/// "snapshot": {...}}. `save: true` = the oracle wrote the file at this
|
||||
/// point with `snapshot` as content; the facade owns the write. The kernel
|
||||
/// is pure — no clock, no filesystem.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn ta_run(arg: *const c_char) -> *mut c_char {
|
||||
envelope(read_arg(arg).and_then(|a| {
|
||||
let mut st = parse_state(a.get("state").ok_or("missing state")?)?;
|
||||
let args = a.get("args").cloned().unwrap_or_else(|| json!({}));
|
||||
let now_us = a.get("now_us").and_then(Value::as_i64).unwrap_or(0);
|
||||
let now_mono = a.get("now_mono").and_then(Value::as_f64).unwrap_or(0.0);
|
||||
let op = a.get("op").and_then(Value::as_str).ok_or("missing op")?;
|
||||
let mut snap = None;
|
||||
let value = run_op(&mut st, op, &args, now_us, now_mono, &mut snap)?;
|
||||
Ok((value, st, snap))
|
||||
}))
|
||||
}
|
||||
|
||||
// NOTE: no ta_free_string export — sibling crates sharing the Python
|
||||
// process already export identical free symbols; a duplicate breaks the
|
||||
// cdylib link. Callers free through capability_scoring's cs_free_string.
|
||||
|
||||
#[path = "branch_battery.rs"]
|
||||
#[cfg(test)]
|
||||
mod branch_battery;
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Port evidence for the time_awareness dichotomy port (#2479 tranche).
|
||||
|
||||
Replays the frozen oracle truth (src/rust/time_awareness/src/
|
||||
frozen_ta_truth.json — generated from the live python oracle with
|
||||
RUNE_TA_CORE=python pinned and the clock faked to fixed instants,
|
||||
7 scenarios / 119 observations) through the wired facade TWICE:
|
||||
|
||||
* python path — RUNE_TA_CORE=python (the pinned fallback)
|
||||
* rust path — the built kernel via ta_loader (skipped when absent)
|
||||
|
||||
The clock is PINNED (fake datetime.now + monotonic via the shared
|
||||
harness in scripts/gen_time_awareness_truth.py), so every observation —
|
||||
result, state, file — is compared verbatim, nothing masked. Facade-level
|
||||
ops (cli / module-singleton) are part of the frozen truth and are driven
|
||||
here through the real facade paths.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(_SCRIPTS))
|
||||
|
||||
import gen_time_awareness_truth as gen # noqa: E402 — shared clock-pinned harness
|
||||
|
||||
_TRUTH = Path(__file__).resolve().parents[1] / "src" / "rust" / "time_awareness" / "src" / "frozen_ta_truth.json"
|
||||
|
||||
|
||||
def _replay(truth, tmp_path, pin):
|
||||
os.environ["RUNE_TA_CORE"] = pin
|
||||
d = gen.Driver(tmp_path)
|
||||
for s in truth["scenarios"]:
|
||||
got = d.run(s["ops"])
|
||||
assert len(got) == len(s["obs"]), s["name"]
|
||||
for i, (g, w) in enumerate(zip(got, s["obs"])):
|
||||
assert g == w, f"{s['name']} op#{i} ({s['ops'][i].get('op')}) diverged"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def truth():
|
||||
with open(_TRUTH) as f:
|
||||
import json
|
||||
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def test_python_fallback_path_replays_frozen_truth(truth, tmp_path):
|
||||
_replay(truth, tmp_path / "py", "python")
|
||||
|
||||
|
||||
def test_rust_kernel_path_replays_frozen_truth(truth, tmp_path):
|
||||
from rune import ta_loader
|
||||
|
||||
if not ta_loader.available():
|
||||
pytest.skip("time_awareness kernel not built")
|
||||
os.environ.pop("RUNE_TA_CORE", None)
|
||||
_replay(truth, tmp_path / "rust", "auto")
|
||||
|
||||
|
||||
def test_naive_last_interaction_raises_typeerror_on_both_pins(tmp_path):
|
||||
"""The oracle's quirk: _load accepts a naive ISO timestamp but
|
||||
get_context then raises TypeError (naive-aware subtraction). The
|
||||
kernel surfaces an envelope error and the facade fallback must raise
|
||||
the very same TypeError — byte-faithful, including the quirk."""
|
||||
gen.set_clock({"op": "x", "now_us": gen.BASE_US, "mono": 0.0})
|
||||
for pin in ("python", "auto"):
|
||||
os.environ["RUNE_TA_CORE"] = pin
|
||||
d = gen.Driver(tmp_path / pin)
|
||||
obs = d.run([
|
||||
{"op": "new", "mono": 0.0,
|
||||
"seed_file": '{"last_interaction": "2026-10-10T10:00:00"}'},
|
||||
{"op": "ctx", "update": False, "now_us": gen.BASE_US, "mono": 0.0},
|
||||
])
|
||||
assert obs[1]["result"] == {"error": "TypeError"}
|
||||
Reference in new issue
Block a user