From a05b4fb437666bf6b6eea338d2b16fcbd878c63f Mon Sep 17 00:00:00 2001 From: sicarii Date: Sat, 19 Sep 2026 20:04:45 -0400 Subject: [PATCH 01/17] roadmap: bind main to f9a2b98 while marketplace verify #7719 is pending Verify issue omacom/omarchy-plugin-marketplace#7719 targets the current main HEAD. Nothing may move main until that commit is verified or the issue is retargeted; this declaration is what the CI freeze gate reads. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QVs5R2j9MoP6nstKodmyk2 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index ad7dc15..92beaa7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -320,7 +320,7 @@ line (`.github/workflows/ci.yml`, job `marketplace-freeze`): while the state is Closing the cycle means editing the line to `state=none`; re-binding means editing `sha=` and nothing else in the same commit, because no commit can name its own SHA. - sia-freeze: state=none branch=main sha=8a624efc911457ae393a72758ade8729de5ba45d + sia-freeze: state=pending branch=main sha=f9a2b9838926904fff236d0c74c3ebd8fa30e487 --- From 6a42f069f7baf24f49f8c816dbb713662139ef41 Mon Sep 17 00:00:00 2001 From: sicarii Date: Sat, 19 Sep 2026 22:04:32 -0400 Subject: [PATCH 02/17] Retire the cached live view with the lane After the maintainer machine moved back to the released lane, `sia status` kept printing the retired lane's last generation ("retained pulse 13620") from the cockpit's cached view, which retirement had left in place beside the moved live generation and candidate. The cached view now moves under the same receipt. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016YDGn1w6rSYf91XfQsEcsx --- bin/siacheckpointcycle.py | 9 +++++++-- tests/test_checkpoint_bootstrap.py | 8 +++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/bin/siacheckpointcycle.py b/bin/siacheckpointcycle.py index 3a96e17..33b3d09 100644 --- a/bin/siacheckpointcycle.py +++ b/bin/siacheckpointcycle.py @@ -254,7 +254,8 @@ def route(owner, *, memo, configured_directory, clock, journal_limits, RETIRED_KEYS = ("controller_source_committed", "controller_checkpoint_chain", "controller_delivery_epoch", "live_loop_committed") RETIRED_FILES = (("live_generation", "LIVE_STATE_PATH"), - ("live_candidate", "LIVE_CANDIDATE_PATH")) + ("live_candidate", "LIVE_CANDIDATE_PATH"), + ("live_view", "LIVE_VIEW_CACHE_PATH")) RETIREMENT_SCHEMA = "sia-controller-source-retirement-v1" RETIREMENT_DIRECTORY = "controller-source-superseded" RETIREMENT_NOTE = ("the checkpoint chain has no rollover; its archives, chain " @@ -312,7 +313,11 @@ def retire(owner, *, memo, apply, reason, retired_at): retired = {key: memo[key] for key in RETIRED_KEYS if key in memo} files = {} for label, name in RETIRED_FILES: - path = owner[name] + # The cached live view is the cockpit's copy of the retired lane's + # last generation; left in place, `sia status` on the released lane + # keeps printing a retained pulse that is no longer being advanced. + path = os.path.join(owner["STATE"], "live-view.json") \ + if name == "LIVE_VIEW_CACHE_PATH" else owner[name] if owner["_live_present"](path): with open(path, "rb") as stream: digest = owner["hashlib"].sha256(stream.read()).hexdigest() diff --git a/tests/test_checkpoint_bootstrap.py b/tests/test_checkpoint_bootstrap.py index cda544c..ec4bf51 100644 --- a/tests/test_checkpoint_bootstrap.py +++ b/tests/test_checkpoint_bootstrap.py @@ -229,7 +229,12 @@ def test_retirement_releases_lane_authority_under_a_receipt(self): # segment's initial batch, not the retired lane in flight. fresh = {k: v for k, v in held.items() if k != "controller_source_committed"} self.assertIsNone(self.cycle.retirement_pending(vars(owner), fresh)) - live_files = [owner.LIVE_STATE_PATH, owner.LIVE_CANDIDATE_PATH] + live_files = [owner.LIVE_STATE_PATH, owner.LIVE_CANDIDATE_PATH, + os.path.join(owner.STATE, "live-view.json")] + import sialiveview + sialiveview.publish_cache(vars(owner)) + self.assertTrue(os.path.exists(os.path.join(owner.STATE, "live-view.json")), + "the fixture published no cached live view") present = [path for path in live_files if os.path.exists(path)] self.assertTrue(present, "the fixture published no live generation") @@ -256,6 +261,7 @@ def test_retirement_releases_lane_authority_under_a_receipt(self): self.assertFalse(owner._controller_source_present(durable)) self.assertFalse(owner._live_started(durable), "a retired live lineage still counts as started") + self.assertIn("live_view", receipt["retired_files"]) for label, row in receipt["retired_files"].items(): self.assertFalse(os.path.exists(row["path"])) self.assertTrue(os.path.isfile(row["retained_as"])) From 6d36119a1b19247fff3f0a10aad9cfc0429fd35f Mon Sep 17 00:00:00 2001 From: sicarii Date: Sat, 19 Sep 2026 22:30:06 -0400 Subject: [PATCH 03/17] sia status: one line for a live loop that is off, not a refusal with its boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the released lane, with the opt-in off and nothing retained, `sia status` printed "live refused · live-view-cache-unavailable" followed by seven boundary lines: an absence described as a failure. It now prints one line saying the lane is off. A retained lane, a pending live loop, an enabled opt-in or a cached view still report through the live view, refusals included, and `sia live` is unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016YDGn1w6rSYf91XfQsEcsx --- bin/sia | 34 ++++++++++++++++++++++++++++++++-- tests/test_cli.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/bin/sia b/bin/sia index 0ad9461..4897c6c 100755 --- a/bin/sia +++ b/bin/sia @@ -573,6 +573,36 @@ def _live_view_text(value): return result +def _live_lane_present(): + """Whether this machine has a live loop for `sia status` to report on. + + The live loop belongs to the opt-in controller-source lane. With the + opt-in off and no retained lane state or cached view, there is nothing + to inspect, and a refusal plus its boundaries would describe an absence + as a failure. Anything unreadable reports as present, so a real refusal + is never hidden. + """ + try: + import sialiveview + memo = sialib.load_memo() + return (sialib._controller_source_enabled() + or sialib._controller_source_present(memo) + or any(key in memo for key in ( + "live_loop_committed", "live_loop_pending", + "controller_checkpoint_chain", "controller_delivery_epoch")) + or os.path.lexists(sialiveview._cache_path(sialib.__dict__))) + except Exception: + return True + + +def _print_live_status_section(): + """The live section of `sia status`: the retained view, or one line saying off.""" + if _live_lane_present(): + return _print_live_summary() + print(" live off · mind.controller_source is false and no live loop is retained") + return {"status": "off"} + + def _print_live_summary(value=None): selected = _read_live_view_result() if value is None else value try: @@ -652,7 +682,7 @@ def cmd_status(): except sialib.OwnerBusy: print("SIA · resident pulse in progress · readiness check deferred") _print_pulse_failure() - selected = _print_live_summary() + selected = _print_live_status_section() return 1 if selected["status"] == "refused" else 0 @@ -758,7 +788,7 @@ def _cmd_status_owned(): print(f" errors {st['errors']}") if sialib.CONFIG_ERRORS: print(f" config {sialib.CONFIG_ERRORS}") - _print_live_summary() + _print_live_status_section() return 0 diff --git a/tests/test_cli.py b/tests/test_cli.py index bcdacf4..ad16f53 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1186,6 +1186,42 @@ def test_status_surfaces_config_health_without_a_prior_pulse(self): self.assertEqual(sia.cmd_status(), 1) self.assertIn("config-invalid-json", output.getvalue()) + def test_status_omits_the_live_view_when_the_opt_in_lane_is_off(self): + """On the released lane `sia status` said "live refused · + live-view-cache-unavailable" followed by seven boundary lines: an + absence described as a failure. With the opt-in off and nothing + retained it now prints one line; a retained lane still reports.""" + import sialiveview + status = _current_status_fixture() + with tempfile.TemporaryDirectory() as state, \ + mock.patch.object(sia.sialib, "STATE", state), \ + mock.patch.object(sia.sialib, "corpus_owner", + return_value=contextlib.nullcontext()), \ + mock.patch.object(sia, "_corpus_owner_nowait", + return_value=contextlib.nullcontext()), \ + mock.patch.object(sia.sialib, "load_thoughts", return_value={"thoughts": []}), \ + mock.patch.object(sia.sialib, "memory_readiness", return_value=(True, "")), \ + mock.patch.object(sia.sialib, "_controller_source_enabled", return_value=False): + with mock.patch.object(sia.sialib, "load_memo", return_value={"pulse_seq": 8}), \ + mock.patch.object(sia.sialib, "read_state_json", + side_effect=(copy.deepcopy(status), _current_graph_fixture(), None)): + output = io.StringIO() + with contextlib.redirect_stdout(output): + sia.cmd_status() + text = output.getvalue() + self.assertIn("live off", text) + self.assertNotIn("live-view-cache-unavailable", text) + self.assertNotIn("boundary This view reports", text) + with mock.patch.object(sia.sialib, "load_memo", + return_value={"pulse_seq": 8, "live_loop_committed": {}}), \ + mock.patch.object(sia.sialib, "read_state_json", + side_effect=(copy.deepcopy(status), _current_graph_fixture(), None)): + output = io.StringIO() + with contextlib.redirect_stdout(output): + sia.cmd_status() + self.assertIn("refused", output.getvalue().lower()) + self.assertNotIn("live off", output.getvalue()) + def test_status_latest_thought_carries_origin(self): status = _current_status_fixture() status["thought"] = { From b7328578765a7ed80ebfa22ee0843b83a7644766 Mon Sep 17 00:00:00 2001 From: sicarii Date: Sat, 19 Sep 2026 22:34:18 -0400 Subject: [PATCH 04/17] sia think: the same one-line live section as sia status when the lane is off Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016YDGn1w6rSYf91XfQsEcsx --- bin/sia | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/sia b/bin/sia index 4897c6c..e70ec02 100755 --- a/bin/sia +++ b/bin/sia @@ -1511,7 +1511,7 @@ def cmd_rehearse(argv): def cmd_think(): - _print_live_summary() + _print_live_status_section() store = sialib.load_thoughts() for t in store["thoughts"][-15:]: mark = "!" if t.get("urgent") else "·" From 30e9792466502637c5e3fe7b82e3b3bfa0fd21af Mon Sep 17 00:00:00 2001 From: sicarii Date: Sat, 19 Sep 2026 22:41:58 -0400 Subject: [PATCH 05/17] Grading never sends an option-shaped claim to the engine A take whose claim is "--help" (registered before `sia take --help` was refused; two such takes are due on the maintainer machine) made the engine's CLI print its usage text instead of a result list, admission refused "grading recall response could not be admitted", and the take stayed due on every nightly run. Such a claim is not recalled at all: a completed recall with no admitted evidence is the documented path on which the judge grades UNRESOLVABLE and closes the take. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016YDGn1w6rSYf91XfQsEcsx --- bin/siatakes.py | 10 ++++++++++ tests/test_siatakes_hardening.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/bin/siatakes.py b/bin/siatakes.py index cabb890..ba47989 100644 --- a/bin/siatakes.py +++ b/bin/siatakes.py @@ -1149,6 +1149,16 @@ def _unverified_jackal_slug(slug): def _recall(query, k=6): + if not isinstance(query, str) or not query.strip() \ + or query.lstrip().startswith("-"): + # An option-shaped or empty claim cannot be recalled: the engine's + # CLI reads it as a flag and prints its usage text instead of a + # result list, which admission then refused on every nightly run, + # leaving the take due forever. Such takes exist from before + # `sia take --help` was refused. A completed recall with no + # admitted evidence is the documented path: the judge sees + # "(none)" and grades UNRESOLVABLE, which closes the take. + return RecallEvidence(True, "", frozenset()) try: # Imported lazily to avoid the sialib -> siatakes module cycle. Every # shipped PGLite operation must enter the same cross-process owner diff --git a/tests/test_siatakes_hardening.py b/tests/test_siatakes_hardening.py index 9a3cf5c..02beaa5 100644 --- a/tests/test_siatakes_hardening.py +++ b/tests/test_siatakes_hardening.py @@ -53,6 +53,21 @@ def test_only_canonical_real_event_and_epoch_paths_are_admitted(self): self.assertFalse( siatakes._admitted_evidence_slug(slug)) + def test_option_shaped_claim_is_never_sent_to_the_engine(self): + """A take whose claim is "--help" (registered before `sia take --help` + was refused) made the engine print its usage text instead of a + result list; admission refused it and the take stayed due forever. + The claim is not recalled at all: a completed recall with no + admitted evidence lets the judge grade it UNRESOLVABLE.""" + with mock.patch.object(sialib, "gbrain", + side_effect=AssertionError("engine queried")): + for claim in ("--help", " -h", "", " "): + recall = siatakes._recall(claim) + self.assertTrue(recall.completed, claim) + self.assertEqual(recall.text, "") + self.assertEqual(recall.citations, frozenset()) + self.assertEqual(recall.reason, "") + def test_model_and_jackal_traversal_refuse_before_judging(self): aliases = ( "events/../takes/model", From e7f324bcff8fd5927d91d3bd906a308cb68f0fb8 Mon Sep 17 00:00:00 2001 From: sicarii Date: Sat, 19 Sep 2026 22:46:42 -0400 Subject: [PATCH 06/17] The judge's config roster admits the shipped "mind" section siatakes reads config.json with its own top-level key roster and fails closed to no judge on any unknown key. The shipped config.example.json carries a "mind" section (the controller-source opt-in) that the roster never learned, so on the maintainer machine, and on any machine started from the example config, `sia grade` reported "judge unavailable" with a working Claude CLI and a configured judge, and grading never ran. The roster now admits every key the runtime's own loader admits; a test holds it to the example config and to sialib's roster. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016YDGn1w6rSYf91XfQsEcsx --- bin/siatakes.py | 6 +++++- tests/test_siatakes_hardening.py | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/bin/siatakes.py b/bin/siatakes.py index ba47989..4c5d1a0 100644 --- a/bin/siatakes.py +++ b/bin/siatakes.py @@ -64,9 +64,13 @@ "schema", "kind", "event", "source_sha256", "target_sha256", "target_size", "target_text", "retire", }) +# Every top-level key the runtime's own config loader admits. A key missing +# here (the "mind" section the shipped config.example.json carries was one) +# makes the judge read the whole config as malformed and fail closed to +# none, silently: grading never ran for anyone on the example config. CONFIG_TOP_LEVEL_KEYS = frozenset({ "_comment", "_egress_trust_boundary", "judge", "senses", "skills", - "custom_senses", "chains", "retrieval", + "custom_senses", "chains", "retrieval", "mind", }) JUDGE_CONFIG_KEYS = frozenset({"_comment", "backend", "model"}) # are deliberately much larger than the admitted grading excerpts while still diff --git a/tests/test_siatakes_hardening.py b/tests/test_siatakes_hardening.py index 02beaa5..21f621e 100644 --- a/tests/test_siatakes_hardening.py +++ b/tests/test_siatakes_hardening.py @@ -53,6 +53,20 @@ def test_only_canonical_real_event_and_epoch_paths_are_admitted(self): self.assertFalse( siatakes._admitted_evidence_slug(slug)) + def test_judge_config_roster_admits_every_shipped_top_level_key(self): + """The judge reads config.json with its own top-level key roster and + fails closed to no judge on any unknown key. The shipped example + config carries a "mind" section the roster lacked, so grading + silently never ran on the maintainer machine (and any machine on + the example config): `sia grade` said "judge unavailable" with a + working Claude CLI. The roster must admit every key the runtime's + own loader admits and every key the example ships.""" + example = json.load(open(os.path.join(REPO, "config.example.json"), + encoding="utf-8")) + self.assertEqual(set(example) - siatakes.CONFIG_TOP_LEVEL_KEYS, set()) + self.assertEqual(sialib._CONFIG_TOP_LEVEL_KEYS - siatakes.CONFIG_TOP_LEVEL_KEYS, + set()) + def test_option_shaped_claim_is_never_sent_to_the_engine(self): """A take whose claim is "--help" (registered before `sia take --help` was refused) made the engine print its usage text instead of a From d57fdda21aad74c8a88312e46831cbb6b36a0d4c Mon Sep 17 00:00:00 2001 From: sicarii Date: Sun, 20 Sep 2026 02:44:19 -0400 Subject: [PATCH 07/17] Cockpit graph: move on the display's frame clock, reveal by the wall clock, rest when settled A 40 ms Timer drove both the graph physics and the twelve-second growth reveal, and the JavaScript canvas it repainted is software-rendered on many machines: the timer fired late, so the replay stretched well past twelve seconds and every frame landed off the display's own beat, which is what "the little thoughts come down slow and choppy" looks like. The physics now steps on FrameAnimation with every per-tick quantity scaled by the real elapsed fraction of a tick (trajectory checked against the 40 ms tick at 8 and 17 ms frames), the reveal advances by elapsed time so it takes twelve seconds at any frame rate, and once nothing moves the loop stops; a ten-frame-per-second breath keeps the fresh-memory glow and the root halo alive. Hover, a new graph, a replay, a resize or a kind toggle wake it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016YDGn1w6rSYf91XfQsEcsx --- Cockpit.qml | 51 ++++++++++++++++++++++++++++++++++++++++++++------- Model.js | 51 ++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 80 insertions(+), 22 deletions(-) diff --git a/Cockpit.qml b/Cockpit.qml index 4cba75b..d0328ba 100644 --- a/Cockpit.qml +++ b/Cockpit.qml @@ -44,6 +44,12 @@ Item { property var hiddenKinds: ({}) property real revealT: 1.0 property bool playing: false + // True while the graph has something to move; every input that can move + // it sets it, and the frame loop clears it once the layout has settled. + property bool layoutLive: true + onPlayingChanged: layoutLive = true + onHoverIdChanged: layoutLive = true + onHiddenKindsChanged: layoutLive = true property string verifyMsg: "" property bool verifyOk: false property string graphBoundary: "" @@ -2278,6 +2284,7 @@ Item { } onCurrentGraphChanged: { + root.layoutLive = true if (root.currentGraph && graphCanvas.width > 0) Model.syncGraph( root.currentGraph, graphCanvas.width, graphCanvas.height) @@ -5477,22 +5484,52 @@ Item { anchors.margins: 2 renderStrategy: Canvas.Cooperative - onWidthChanged: if (root.currentGraph && width > 0) + onWidthChanged: if (root.currentGraph && width > 0) { Model.syncGraph(root.currentGraph, width, height) - onHeightChanged: if (root.currentGraph && width > 0) + root.layoutLive = true + } + onHeightChanged: if (root.currentGraph && width > 0) { Model.syncGraph(root.currentGraph, width, height) + root.layoutLive = true + } - Timer { - interval: 40 + // The graph moves on the display's own frame clock and stops + // when nothing moves. A 40 ms Timer used to drive both the + // physics and the growth reveal: under the software-rendered + // canvas it fired late, so the 12-second replay stretched + // and every frame landed off the display's beat. + FrameAnimation { + id: graphFrames running: root.opened && root.currentGraph !== null - repeat: true + && root.layoutLive onTriggered: { + var ms = frameTime * 1000 + if (!(ms > 0) || ms > 100) ms = 40 if (root.playing) { - root.revealT = Math.min(1, root.revealT + 40 / 12000) + // Wall-clock reveal: twelve seconds regardless of frame rate. + root.revealT = Math.min(1, root.revealT + ms / 12000) if (root.revealT >= 1) root.playing = false } Model.step(root.currentGraph, - graphCanvas.width, graphCanvas.height, root.revealT) + graphCanvas.width, graphCanvas.height, + root.revealT, ms) + graphCanvas.requestPaint() + if (!root.playing && root.hoverId === "" && Model.settled()) + root.layoutLive = false + } + } + + // Settled: nothing moves, but fresh memories breathe and the root + // keeps its halo. Ten slow frames a second carry that; the + // physics does not run again until something changes. + Timer { + id: graphBreath + interval: 100 + repeat: true + running: root.opened && root.currentGraph !== null + && !root.layoutLive + onTriggered: { + Model.breathe(interval) graphCanvas.requestPaint() } } diff --git a/Model.js b/Model.js index a231239..00ef7b0 100644 --- a/Model.js +++ b/Model.js @@ -1581,7 +1581,7 @@ function resetLayout() { L.pos = {}; L.seeded = false; L.replaySeed = false; L.phase = 0 L.adj = {}; L.edgesByNode = {}; L.rings = []; L.targetAngle = {} L.sectorWidth = {} - L.width = 0; L.height = 0 + L.width = 0; L.height = 0; L.maxSpeed = 0 } function replayLayout(graph, w, h) { @@ -1806,7 +1806,16 @@ function syncGraph(graph, w, h) { L.seeded = true } -function step(graph, w, h, revealT) { +function step(graph, w, h, revealT, dtMs) { + // Forces, damping and the speed cap were tuned per 40 ms tick. A frame + // clock delivers uneven intervals (8 ms at 120 Hz, longer under load), so + // every per-tick quantity is scaled by the elapsed fraction of a tick and + // the trajectory stays the same at any frame rate. Velocity stays in px per + // tick, so the cap below keeps its meaning. + var s = (typeof dtMs === "number" && isFinite(dtMs) && dtMs > 0) + ? Math.min(2.5, dtMs / 40) : 1 + var damp = Math.pow(0.82, s) + L.maxSpeed = 0 if (!graph || !graph.nodes || !L.seeded) return var nodes = graph.nodes, edges = graph.edges var cx = w / 2, cy = h / 2, half = Math.min(w, h) / 2 @@ -1835,8 +1844,8 @@ function step(graph, w, h, revealT) { } f = K_REP / d2 d = Math.sqrt(d2) - a.vx += (dx / d) * f; a.vy += (dy / d) * f - b.vx -= (dx / d) * f; b.vy -= (dy / d) * f + a.vx += (dx / d) * f * s; a.vy += (dy / d) * f * s + b.vx -= (dx / d) * f * s; b.vy -= (dy / d) * f * s } } for (i = 0; i < edges.length; i++) { @@ -1848,8 +1857,8 @@ function step(graph, w, h, revealT) { dx = b.x - a.x; dy = b.y - a.y d = Math.sqrt(dx * dx + dy * dy) || 1 f = K_SPRING * (d - REST) - a.vx += (dx / d) * f; a.vy += (dy / d) * f - b.vx -= (dx / d) * f; b.vy -= (dy / d) * f + a.vx += (dx / d) * f * s; a.vy += (dy / d) * f * s + b.vx -= (dx / d) * f * s; b.vy -= (dy / d) * f * s } for (i = 0; i < nodes.length; i++) { var n = nodes[i] @@ -1863,8 +1872,8 @@ function step(graph, w, h, revealT) { var organAngle = L.targetAngle[activeKey] var organX = cx + Math.cos(organAngle) * R_ORGAN * half var organY = cy + Math.sin(organAngle) * R_ORGAN * half - a.vx += (organX - a.x) * K_ORGAN - a.vy += (organY - a.y) * K_ORGAN + a.vx += (organX - a.x) * K_ORGAN * s + a.vy += (organY - a.y) * K_ORGAN * s } else { // Time owns radius; semantic ownership softly owns angle. The latter is // a tether, not a fixed point, so repulsion and links can still arrange @@ -1872,22 +1881,34 @@ function step(graph, w, h, revealT) { dx = a.x - cx; dy = a.y - cy var r = Math.sqrt(dx * dx + dy * dy) || 1 var want = targetRadius(n, half) - a.vx += (dx / r) * (want - r) * K_RAD - a.vy += (dy / r) * (want - r) * K_RAD + a.vx += (dx / r) * (want - r) * K_RAD * s + a.vy += (dy / r) * (want - r) * K_RAD * s var turn = angleDelta(L.targetAngle[activeKey], Math.atan2(dy, dx)) * r * K_ANGLE - a.vx += (-dy / r) * turn - a.vy += (dx / r) * turn + a.vx += (-dy / r) * turn * s + a.vy += (dx / r) * turn * s } - a.vx *= DAMP; a.vy *= DAMP + a.vx *= damp; a.vy *= damp var vm = Math.sqrt(a.vx * a.vx + a.vy * a.vy) if (vm > 6) { a.vx *= 6 / vm; a.vy *= 6 / vm } - a.x += a.vx; a.y += a.vy + if (vm > L.maxSpeed) L.maxSpeed = vm + a.x += a.vx * s; a.y += a.vy * s var m = Math.max(12, nodeRadius(n) + 6) if (a.x < m) a.x = m; if (a.x > w - m) a.x = w - m if (a.y < m) a.y = m; if (a.y > h - m) a.y = h - m } - L.phase += 0.03 + L.phase += 0.03 * s +} + +function breathe(dtMs) { + // Advance only the glow phase, at the same rate a 40 ms tick advanced it. + L.phase += 0.03 * ((typeof dtMs === "number" && dtMs > 0) ? dtMs / 40 : 1) +} + +function settled() { + // Motion below a fifth of a pixel per tick is not visible; the frame loop + // stops there and restarts on any input, so the settled graph costs nothing. + return (L.maxSpeed || 0) < 0.2 } // Candidate label centers, ordered from the node's outward radial side to From f19f2b08d036c8fa0e97f51ed506fce43e80761e Mon Sep 17 00:00:00 2001 From: sicarii Date: Sun, 20 Sep 2026 05:06:09 -0400 Subject: [PATCH 08/17] Cockpit graph: integrate in sub-ticks so long frames settle instead of running forever With the cockpit open and the graph sitting still, the shell used about 64% of a core (2% closed). The physics was tuned per 40 ms tick and scaled by the elapsed frame time, capped at 2.5 ticks. Fed the 100 ms frames a software renderer routinely delivers, the explicit integrator overshot its own springs and never crossed the settle threshold, so the frame loop ran at full rate forever; it dipped to 4% only when frames happened to be short, and every pulse's graph reload restarted it. The elapsed time is now spent in sub-ticks of at most one tuned tick, so a 100 ms frame settles in the same wall time as a 17 ms one (about six seconds on the maintainer graph at every rate) and the breath timer takes over. The regression test uses a graph shape on which the old integrator provably never settles at 100 ms; the label test's literal follows the frame-clock call shape. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QVs5R2j9MoP6nstKodmyk2 --- Model.js | 30 +++++++++++++---- tests/test_graph_layout.py | 69 +++++++++++++++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 8 deletions(-) diff --git a/Model.js b/Model.js index 00ef7b0..d71e542 100644 --- a/Model.js +++ b/Model.js @@ -1809,14 +1809,29 @@ function syncGraph(graph, w, h) { function step(graph, w, h, revealT, dtMs) { // Forces, damping and the speed cap were tuned per 40 ms tick. A frame // clock delivers uneven intervals (8 ms at 120 Hz, longer under load), so - // every per-tick quantity is scaled by the elapsed fraction of a tick and - // the trajectory stays the same at any frame rate. Velocity stays in px per - // tick, so the cap below keeps its meaning. - var s = (typeof dtMs === "number" && isFinite(dtMs) && dtMs > 0) + // the elapsed time is spent in sub-ticks of at most one tuned tick each: + // an explicit integrator fed a 2.5-tick step overshoots its own springs + // and never settles (under a software renderer with 100 ms frames the + // layout loop then runs at full frame rate forever, which is what a + // cockpit sitting still at 64% of a core was). Velocity stays in px per + // tick, so the cap below keeps its meaning; a settled graph still costs + // exactly one sub-tick to confirm it is settled. + var total = (typeof dtMs === "number" && isFinite(dtMs) && dtMs > 0) ? Math.min(2.5, dtMs / 40) : 1 - var damp = Math.pow(0.82, s) + var parts = Math.max(1, Math.ceil(total - 1e-9)) L.maxSpeed = 0 - if (!graph || !graph.nodes || !L.seeded) return + for (var k = 0; k < parts; k++) { + var peak = stepOnce(graph, w, h, revealT, total / parts) + if (peak > L.maxSpeed) L.maxSpeed = peak + } +} + +function stepOnce(graph, w, h, revealT, s) { + // One integration sub-tick of `s` tuned ticks (0 < s <= 1). Returns the + // fastest node's speed this sub-tick; step() keeps the maximum. + var damp = Math.pow(0.82, s) + var maxSpeed = 0 + if (!graph || !graph.nodes || !L.seeded) return 0 var nodes = graph.nodes, edges = graph.edges var cx = w / 2, cy = h / 2, half = Math.min(w, h) / 2 var i, j, a, b, dx, dy, d2, d, f @@ -1891,13 +1906,14 @@ function step(graph, w, h, revealT, dtMs) { a.vx *= damp; a.vy *= damp var vm = Math.sqrt(a.vx * a.vx + a.vy * a.vy) if (vm > 6) { a.vx *= 6 / vm; a.vy *= 6 / vm } - if (vm > L.maxSpeed) L.maxSpeed = vm + if (vm > maxSpeed) maxSpeed = vm a.x += a.vx * s; a.y += a.vy * s var m = Math.max(12, nodeRadius(n) + 6) if (a.x < m) a.x = m; if (a.x > w - m) a.x = w - m if (a.y < m) a.y = m; if (a.y > h - m) a.y = h - m } L.phase += 0.03 * s + return maxSpeed } function breathe(dtMs) { diff --git a/tests/test_graph_layout.py b/tests/test_graph_layout.py index 4f8588e..bf7aa3e 100644 --- a/tests/test_graph_layout.py +++ b/tests/test_graph_layout.py @@ -136,6 +136,73 @@ def test_replay_reseeds_growth_and_hidden_nodes_do_not_settle_early(self): metrics["startRadius"] + 100, metrics) self.assertTrue(metrics["deterministic"], metrics) + def test_long_frames_settle_instead_of_running_forever(self): + """The frame loop stops when the layout settles; long frames must let it. + + The physics was tuned per 40 ms tick and scaled by the elapsed frame + time. Fed a 100 ms frame (the step scale's 2.5 cap, routine under a + software renderer), the explicit integrator overshot its own springs + and never crossed the settle threshold, so the cockpit's frame loop + ran at full rate forever: a graph sitting still cost 64% of a core. + Time is now spent in sub-ticks of at most one tuned tick, so a + 100 ms frame settles in the same wall time as a 17 ms one, and a + frame of exactly 2.5 ticks follows the trajectory of 2.5 sub-ticks. + """ + metrics = self._run_model(r''' +function build() { + const nodes = [{id: "sia/cortex", t: "organ", ts: "", deg: 1}] + const edges = [] + for (let o = 0; o < 8; o++) { + nodes.push({id: "organs/o" + o, t: "organ", ts: "", deg: 12}) + edges.push({s: "organs/o" + o, d: "sia/cortex", t: "mentions"}) + for (let k = 0; k < 60; k++) { + const id = "events/o" + o + "/d" + k + nodes.push({id, t: "event-day", deg: 2, + ts: "2026-09-" + String(1 + (k % 28)).padStart(2, "0") + "T00:00:00Z"}) + edges.push({s: id, d: "organs/o" + o, t: "mentions"}) + if (k) edges.push({s: id, d: "events/o" + o + "/d" + (k - 1), t: "mentions"}) + // One cross-organ link per memory: the density at which a 2.5-tick + // step made the old integrator oscillate forever. + edges.push({s: id, d: "events/o" + ((o + 1) % 8) + "/d" + ((k * 7) % 60), t: "mentions"}) + } + } + return {nodes, edges} +} +function scatter(graph) { + // Seeding places nodes near their targets; a real pulse's fresh nodes and + // a resized canvas do not. Start every memory far from where it belongs, + // deterministically, so the layout has real distance to travel. + let k = 0 + for (const id in L.pos) { + if (id === "sia/cortex") continue + L.pos[id].x = 60 + ((k * 587) % 1880) + L.pos[id].y = 60 + ((k * 733) % 1680) + L.pos[id].vx = 0; L.pos[id].vy = 0 + k++ + } +} +function settleTime(dtMs) { + const graph = build() + // Each run seeds from nothing; the layout state is module-global. + L.pos = {}; L.seeded = false; L.maxSpeed = 0 + syncGraph(graph, 2000, 1800) + for (let tick = 1; tick <= 5000; tick++) { + step(graph, 2000, 1800, 1.0, dtMs) + if (settled()) return tick * dtMs / 1000 + } + return -1 +} +const walls = {fast: settleTime(16.7), tick: settleTime(40), slow: settleTime(100)} +return {walls, nodes: build().nodes.length} +''') + walls = metrics["walls"] + for name in ("fast", "tick", "slow"): + self.assertGreater(walls[name], 0, metrics) + self.assertLess(walls[name], 30, metrics) + # Same order of wall-clock settle time across frame rates; the 100 ms + # case used to be -1 (never) on this exact graph. + self.assertLess(max(walls.values()), 3 * min(walls.values()), metrics) + def test_label_candidates_begin_outward_and_ui_uses_collision_guard(self): candidates = self._run_model(r''' return { @@ -148,7 +215,7 @@ def test_label_candidates_begin_outward_and_ui_uses_collision_guard(self): cockpit = _read("Cockpit.qml") self.assertIn("Model.replayLayout", cockpit) - self.assertIn("root.revealT)", cockpit) + self.assertIn("root.revealT, ms)", cockpit) self.assertIn("nodeObstacles", cockpit) self.assertIn("placedLabels", cockpit) self.assertIn("Model.labelCandidates", cockpit) From cebe7f8902663c2b0c23763bad426501f72a981e Mon Sep 17 00:00:00 2001 From: sicarii Date: Sun, 20 Sep 2026 06:22:29 -0400 Subject: [PATCH 09/17] Cockpit graph: seed only on a real canvas, key nodes once per tick, breathe on a glow layer Measured on the shell with the cockpit open (per-thread /proc sampling, a probe timer writing the frame loop's state every two seconds): - 63% of a core whenever a graph was on screen, 2% during the window a pulse rewrites graph.json ahead of status.json, forever. The layout was never settling: its first seed ran when the canvas reported width 1040 and height 0, so every memory sat on one point at the top edge (a ring of radius zero), and escaping that took 392 ticks of violent repulsion. Under the shell's 220 ms software frames, clamped to one 40 ms tick each, that is 86 seconds, and every pulse interrupted it. Model.syncGraph now refuses a zero-size canvas; the resize handlers seed once both dimensions exist. The first real-size seed settles exactly as a fresh one (139 ticks on the live 260-memory graph). - A tick keyed L.pos by string inside the all-pairs loop: two strings per pair, 67,340 per tick on that graph. Keys are built once per node; the trajectory is bit-identical (verified on 300 mixed-length frames) and a tick costs 13x less in Node. - A slow frame advances the layout by the time it took, up to 250 ms in sub-ticks, instead of one tick, so a 220 ms frame moves 220 ms of wall clock. The 100 ms case in the settle test is unchanged. - The breath (fresh glow, root halo) moved to its own canvas above the graph, following every graph paint through onPainted, at 5 Hz. A settled graph never repaints its 260-node canvas to breathe: on a software renderer at 2x scale that canvas costs about 89 ms a frame (40% of a core at the 4.5 fps the surface delivers here, all of it in QRasterPaintEngine stroke). After: a settled open cockpit is 5% main thread + 6% render thread; the layout settles about 6 s after opening and 2-10 s after a pulse, then rests. Verified end-to-end by restarting the shell (an edited plugin file is NOT reloaded by "Local plugin changed, reloading" nor by setPluginEnabled) and sampling 95 s across two pulses. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QVs5R2j9MoP6nstKodmyk2 --- Cockpit.qml | 105 ++++++++++++++++++++++++++---------- Model.js | 64 ++++++++++++++-------- tests/test_graph_layout.py | 107 +++++++++++++++++++++++++++++++++++++ 3 files changed, 225 insertions(+), 51 deletions(-) diff --git a/Cockpit.qml b/Cockpit.qml index d0328ba..48d4dd7 100644 --- a/Cockpit.qml +++ b/Cockpit.qml @@ -2232,7 +2232,7 @@ Item { graphFile.reload(); thoughtsFile.reload() continuityFile.reload() continuityScheduleRefresh.restart() - if (root.currentGraph && graphCanvas.width > 0) + if (root.currentGraph && graphCanvas.width > 0 && graphCanvas.height > 0) Model.syncGraph( root.currentGraph, graphCanvas.width, graphCanvas.height) Qt.callLater(function() { @@ -2285,7 +2285,7 @@ Item { onCurrentGraphChanged: { root.layoutLive = true - if (root.currentGraph && graphCanvas.width > 0) + if (root.currentGraph && graphCanvas.width > 0 && graphCanvas.height > 0) Model.syncGraph( root.currentGraph, graphCanvas.width, graphCanvas.height) if (!root.currentGraph) { @@ -2351,7 +2351,7 @@ Item { ? "last good graph; latest graph rejected" : "no valid graph snapshot" return } - if (graphCanvas.width > 0) + if (graphCanvas.width > 0 && graphCanvas.height > 0) Model.syncGraph(g, graphCanvas.width, graphCanvas.height) root.graph = g root.graphBoundary = "" @@ -5483,12 +5483,18 @@ Item { anchors.fill: parent anchors.margins: 2 renderStrategy: Canvas.Cooperative - - onWidthChanged: if (root.currentGraph && width > 0) { + // The glow layer follows every graph frame, so it never shows + // a halo where a node no longer is. + onPainted: glowCanvas.requestPaint() + + // Both dimensions, not just width: the first size a canvas + // reports has its width and a zero height, and Model refuses to + // seed a layout on a zero-size canvas. + onWidthChanged: if (root.currentGraph && width > 0 && height > 0) { Model.syncGraph(root.currentGraph, width, height) root.layoutLive = true } - onHeightChanged: if (root.currentGraph && width > 0) { + onHeightChanged: if (root.currentGraph && width > 0 && height > 0) { Model.syncGraph(root.currentGraph, width, height) root.layoutLive = true } @@ -5503,8 +5509,14 @@ Item { running: root.opened && root.currentGraph !== null && root.layoutLive onTriggered: { + // A slow frame advances the layout by the time it took, up + // to 250 ms (Model integrates it in sub-ticks). Treating a + // slow frame as one 40 ms tick made wall-clock convergence + // six times slower on the 220 ms frames of a software + // renderer, so the loop ran through every pulse. var ms = frameTime * 1000 - if (!(ms > 0) || ms > 100) ms = 40 + if (!(ms > 0)) ms = 40 + else if (ms > 250) ms = 250 if (root.playing) { // Wall-clock reveal: twelve seconds regardless of frame rate. root.revealT = Math.min(1, root.revealT + ms / 12000) @@ -5520,17 +5532,20 @@ Item { } // Settled: nothing moves, but fresh memories breathe and the root - // keeps its halo. Ten slow frames a second carry that; the - // physics does not run again until something changes. + // keeps its halo. Five slow frames a second carry that, and only + // on glowCanvas: a full graph frame costs about 90 ms under a + // software renderer at 2x scale, and ten of those a second held + // a settled cockpit at 40% of a core. The physics does not run + // again until something changes. Timer { id: graphBreath - interval: 100 + interval: 200 repeat: true running: root.opened && root.currentGraph !== null && !root.layoutLive onTriggered: { Model.breathe(interval) - graphCanvas.requestPaint() + glowCanvas.requestPaint() } } @@ -5601,27 +5616,10 @@ Item { top: p.y - r - 3, bottom: p.y + r + 3 }) var col = Model.nodeColor(n, root.pal) - var fresh = Model.freshness(n, now) - if (fresh > 0.02 && !dimmed) { - var breathe = 0.75 + 0.25 * Math.sin(Model.phase() * 2 - + p.x * 0.05) - ctx.fillStyle = Qt.alpha(root.accent, 0.28 * fresh * breathe) - ctx.beginPath() - ctx.arc(p.x, p.y, r + 5 + 4 * fresh, 0, 2 * Math.PI) - ctx.fill() - } + // The fresh glow and the root halo breathe on glowCanvas. ctx.fillStyle = dimmed ? Qt.alpha(col, 0.22) : col ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, 2 * Math.PI) ctx.fill() - if (n.id === "sia/cortex" && !dimmed) { - var halo = 0.35 + 0.20 * Math.sin(Model.phase()) - ctx.strokeStyle = Qt.alpha(root.fg, halo) - ctx.lineWidth = 1.2 - ctx.beginPath() - ctx.arc(p.x, p.y, r + 3.5, 0, 2 * Math.PI) - ctx.stroke() - ctx.lineWidth = 1 - } if (n.id === eff) { ctx.strokeStyle = root.fg ctx.beginPath() @@ -5750,6 +5748,55 @@ Item { } } + // The breath layer: fresh memories glow and the root keeps its + // halo. A settled graph repaints nothing else, so breathing costs + // a handful of arcs, not the whole graph. It does not take the + // pointer; the graph's own MouseArea beneath it keeps hover. + Canvas { + id: glowCanvas + anchors.fill: graphCanvas + renderStrategy: Canvas.Cooperative + onPaint: { + var ctx = getContext("2d") + ctx.reset() + ctx.clearRect(0, 0, width, height) + var graph = root.currentGraph + if (!graph || !graph.nodes) return + var now = root.nowMs > 0 ? root.nowMs : Date.now() + var eff = root.effId + var nbrs = eff !== "" ? Model.neighbors(eff) : null + var nodes = graph.nodes + for (var i = 0; i < nodes.length; i++) { + var n = nodes[i] + if (!root.nodeVisible(n)) continue + var p = Model.posOf(n.id) + if (!p) continue + if (eff !== "" && n.id !== eff + && !Model.hasNeighbor(nbrs, n.id)) continue + var r = Model.nodeRadius(n) + var fresh = Model.freshness(n, now) + if (fresh > 0.02) { + var breathe = 0.75 + 0.25 * Math.sin(Model.phase() * 2 + + p.x * 0.05) + ctx.fillStyle = Qt.alpha(root.accent, 0.28 * fresh * breathe) + // A ring, not a disc: the node's own fill stays untinted. + ctx.beginPath() + ctx.arc(p.x, p.y, r + 5 + 4 * fresh, 0, 2 * Math.PI) + ctx.arc(p.x, p.y, r, 0, 2 * Math.PI, true) + ctx.fill() + } + if (n.id === "sia/cortex") { + var halo = 0.35 + 0.20 * Math.sin(Model.phase()) + ctx.strokeStyle = Qt.alpha(root.fg, halo) + ctx.lineWidth = 1.2 + ctx.beginPath() + ctx.arc(p.x, p.y, r + 3.5, 0, 2 * Math.PI) + ctx.stroke() + } + } + } + } + Text { textFormat: Text.PlainText renderType: Text.NativeRendering diff --git a/Model.js b/Model.js index d71e542..4d1421a 100644 --- a/Model.js +++ b/Model.js @@ -1623,6 +1623,12 @@ function targetRadius(n, half) { function syncGraph(graph, w, h) { if (!graph || !graph.nodes) return + // A canvas reports its width before its height while the overlay is laid + // out. Seeding against a zero dimension put every memory on one point (a + // ring of radius zero), and the layout then spent minutes of violent + // repulsion escaping it, one clamped tick per 240 ms software frame. + // Wait for a real canvas; the resize handlers seed once it has one. + if (!(w > 0) || !(h > 0)) return var cx = w / 2, cy = h / 2, half = Math.min(w, h) / 2 var i, n @@ -1813,11 +1819,13 @@ function step(graph, w, h, revealT, dtMs) { // an explicit integrator fed a 2.5-tick step overshoots its own springs // and never settles (under a software renderer with 100 ms frames the // layout loop then runs at full frame rate forever, which is what a - // cockpit sitting still at 64% of a core was). Velocity stays in px per - // tick, so the cap below keeps its meaning; a settled graph still costs - // exactly one sub-tick to confirm it is settled. + // cockpit sitting still at 64% of a core was). Up to 250 ms of motion + // (6.25 ticks) is spent per call, so a 220 ms frame moves the layout by + // 220 ms of wall clock. Velocity stays in px per tick, so the cap below + // keeps its meaning; a settled graph still costs exactly one sub-tick to + // confirm it is settled. var total = (typeof dtMs === "number" && isFinite(dtMs) && dtMs > 0) - ? Math.min(2.5, dtMs / 40) : 1 + ? Math.min(6.25, dtMs / 40) : 1 var parts = Math.max(1, Math.ceil(total - 1e-9)) L.maxSpeed = 0 for (var k = 0; k < parts; k++) { @@ -1836,18 +1844,29 @@ function stepOnce(graph, w, h, revealT, s) { var cx = w / 2, cy = h / 2, half = Math.min(w, h) / 2 var i, j, a, b, dx, dy, d2, d, f var K_REP = 760, K_SPRING = 0.009, REST = 44 - var K_RAD = 0.085, K_ANGLE = 0.032, K_ORGAN = 0.16, DAMP = 0.82 - var active = {} - for (i = 0; i < nodes.length; i++) - active[graphMapKey(nodes[i].id)] = nodes[i].id === "sia/cortex" + var K_RAD = 0.085, K_ANGLE = 0.032, K_ORGAN = 0.16 + // Index the graph once per sub-tick. The pair loop below visits every + // node pair, and keying L.pos by string inside it built and discarded two + // strings per pair: 67 000 per tick on a 260-memory graph. Under the + // shell's interpreter that allocation, not the arithmetic, was the tick, + // and a tick was most of a 240 ms frame. Keys are built once per node. + var count = nodes.length + var keys = new Array(count), pos = new Array(count) + var active = new Array(count), index = {} + for (i = 0; i < count; i++) { + keys[i] = graphMapKey(nodes[i].id) + index[keys[i]] = i + pos[i] = L.pos[keys[i]] || null + active[i] = nodes[i].id === "sia/cortex" || nodes[i].t === "organ" || revealT === undefined || (nodes[i].tsNorm || 0) <= revealT - for (i = 0; i < nodes.length; i++) { - if (!active[graphMapKey(nodes[i].id)]) continue - a = L.pos[graphMapKey(nodes[i].id)]; if (!a) continue - for (j = i + 1; j < nodes.length; j++) { - if (!active[graphMapKey(nodes[j].id)]) continue - b = L.pos[graphMapKey(nodes[j].id)]; if (!b) continue + } + for (i = 0; i < count; i++) { + if (!active[i]) continue + a = pos[i]; if (!a) continue + for (j = i + 1; j < count; j++) { + if (!active[j]) continue + b = pos[j]; if (!b) continue dx = a.x - b.x; dy = a.y - b.y d2 = dx * dx + dy * dy if (d2 > 26000) continue @@ -1864,10 +1883,11 @@ function stepOnce(graph, w, h, revealT, s) { } } for (i = 0; i < edges.length; i++) { - if (!active[graphMapKey(edges[i].s)] - || !active[graphMapKey(edges[i].d)]) continue - a = L.pos[graphMapKey(edges[i].s)] - b = L.pos[graphMapKey(edges[i].d)] + var si = index[graphMapKey(edges[i].s)] + var di = index[graphMapKey(edges[i].d)] + if (si === undefined || di === undefined + || !active[si] || !active[di]) continue + a = pos[si]; b = pos[di] if (!a || !b) continue dx = b.x - a.x; dy = b.y - a.y d = Math.sqrt(dx * dx + dy * dy) || 1 @@ -1875,11 +1895,11 @@ function stepOnce(graph, w, h, revealT, s) { a.vx += (dx / d) * f * s; a.vy += (dy / d) * f * s b.vx -= (dx / d) * f * s; b.vy -= (dy / d) * f * s } - for (i = 0; i < nodes.length; i++) { + for (i = 0; i < count; i++) { var n = nodes[i] - var activeKey = graphMapKey(n.id) - a = L.pos[activeKey]; if (!a) continue - if (!active[activeKey]) { a.vx = 0; a.vy = 0; continue } + var activeKey = keys[i] + a = pos[i]; if (!a) continue + if (!active[i]) { a.vx = 0; a.vy = 0; continue } if (n.id === "sia/cortex") { a.x = cx; a.y = cy; a.vx = 0; a.vy = 0 continue diff --git a/tests/test_graph_layout.py b/tests/test_graph_layout.py index bf7aa3e..5513f4e 100644 --- a/tests/test_graph_layout.py +++ b/tests/test_graph_layout.py @@ -203,6 +203,97 @@ def test_long_frames_settle_instead_of_running_forever(self): # case used to be -1 (never) on this exact graph. self.assertLess(max(walls.values()), 3 * min(walls.values()), metrics) + def test_zero_size_canvas_never_seeds_the_layout(self): + """A canvas reports width before height; a zero dimension must not seed. + + Seeding against height 0 placed every memory on one point (a ring of + radius zero). The layout then spent minutes of violent repulsion + escaping it, and on the shell's 240 ms software frames the frame loop + ran through every pulse: the cockpit sat at 63% of a core. Model now + refuses a zero-size canvas and the first real size seeds as fresh. + """ + metrics = self._run_model(r""" +function build() { + const nodes = [{id: "sia/cortex", t: "organ", ts: "", deg: 1}] + const edges = [] + for (let o = 0; o < 6; o++) { + nodes.push({id: "organs/o" + o, t: "organ", ts: "", deg: 12}) + edges.push({s: "organs/o" + o, d: "sia/cortex", t: "mentions"}) + for (let k = 0; k < 30; k++) { + const id = "events/o" + o + "/d" + k + nodes.push({id, t: "event-day", deg: 2, + ts: "2026-09-" + String(1 + (k % 28)).padStart(2, "0") + "T00:00:00Z"}) + edges.push({s: id, d: "organs/o" + o, t: "mentions"}) + if (k) edges.push({s: id, d: "events/o" + o + "/d" + (k - 1), t: "mentions"}) + } + } + return {nodes, edges} +} +function settleTicks(graph) { + for (let tick = 1; tick <= 5000; tick++) { + step(graph, 1040, 919, 1.0, 40) + if (settled()) return tick + } + return -1 +} +const graph = build() +L.pos = {}; L.seeded = false; L.maxSpeed = 0 +syncGraph(graph, 1040, 0) +const zero = {seeded: L.seeded, positions: Object.keys(L.pos).length} +step(graph, 1040, 919, 1.0, 40) +zero.settledWhileUnseeded = settled() +syncGraph(graph, 1040, 919) +const ys = new Set(Object.values(L.pos).map(p => p.y.toFixed(0))) +const afterResize = {seeded: L.seeded, distinctY: ys.size, ticks: settleTicks(graph)} +const fresh = build() +L.pos = {}; L.seeded = false; L.maxSpeed = 0 +syncGraph(fresh, 1040, 919) +return {zero, afterResize, freshTicks: settleTicks(fresh)} +""") + self.assertFalse(metrics["zero"]["seeded"], metrics) + self.assertEqual(metrics["zero"]["positions"], 0, metrics) + # An unseeded layout has nothing to move, so the frame loop rests. + self.assertTrue(metrics["zero"]["settledWhileUnseeded"], metrics) + self.assertTrue(metrics["afterResize"]["seeded"], metrics) + self.assertGreater(metrics["afterResize"]["distinctY"], 20, metrics) + self.assertGreater(metrics["afterResize"]["ticks"], 0, metrics) + self.assertEqual( + metrics["afterResize"]["ticks"], metrics["freshTicks"], metrics) + + def test_pair_loop_keys_each_node_once_per_tick(self): + """The physics keys positions once per node, not once per node pair. + + Building a string key inside the all-pairs repulsion loop allocated + two strings per pair (67 000 per tick on a 260-memory graph), and + under the shell's interpreter that allocation was most of a 240 ms + frame. The trajectory is unchanged; only the keying is hoisted. + """ + metrics = self._run_model(r""" +const graph = {nodes: [{id: "sia/cortex", t: "organ", ts: "", deg: 1}], edges: []} +for (let o = 0; o < 4; o++) { + graph.nodes.push({id: "organs/o" + o, t: "organ", ts: "", deg: 8}) + graph.edges.push({s: "organs/o" + o, d: "sia/cortex", t: "mentions"}) + for (let k = 0; k < 50; k++) { + const id = "events/o" + o + "/d" + k + graph.nodes.push({id, t: "event-day", deg: 2, + ts: "2026-09-" + String(1 + (k % 28)).padStart(2, "0") + "T00:00:00Z"}) + graph.edges.push({s: id, d: "organs/o" + o, t: "mentions"}) + } +} +L.pos = {}; L.seeded = false; L.maxSpeed = 0 +syncGraph(graph, 1040, 919) +const original = graphMapKey +let calls = 0 +graphMapKey = function (value) { calls++; return original(value) } +step(graph, 1040, 919, 1.0, 40) +graphMapKey = original +return {calls, nodes: graph.nodes.length, edges: graph.edges.length} +""") + self.assertGreater(metrics["calls"], 0, metrics) + self.assertLessEqual( + metrics["calls"], + metrics["nodes"] + 2 * metrics["edges"], metrics) + def test_label_candidates_begin_outward_and_ui_uses_collision_guard(self): candidates = self._run_model(r''' return { @@ -216,6 +307,22 @@ def test_label_candidates_begin_outward_and_ui_uses_collision_guard(self): cockpit = _read("Cockpit.qml") self.assertIn("Model.replayLayout", cockpit) self.assertIn("root.revealT, ms)", cockpit) + # A slow frame advances the layout by the time it took (to 250 ms), + # never by one tick. + self.assertIn("else if (ms > 250) ms = 250", cockpit) + self.assertNotIn("ms > 100) ms = 40", cockpit) + # The breath repaints only the glow layer, which follows every graph + # frame; a settled graph never repaints its 260-node canvas to breathe. + self.assertIn("id: glowCanvas", cockpit) + self.assertIn("onPainted: glowCanvas.requestPaint()", cockpit) + breath = cockpit[cockpit.index("id: graphBreath"):] + breath = breath[:breath.index("onPaint:")] + self.assertIn("glowCanvas.requestPaint()", breath) + self.assertNotIn("graphCanvas.requestPaint()", breath) + # Model refuses a zero-size canvas; the resize handlers wait for both. + self.assertIn( + "onHeightChanged: if (root.currentGraph && width > 0 && height > 0)", + cockpit) self.assertIn("nodeObstacles", cockpit) self.assertIn("placedLabels", cockpit) self.assertIn("Model.labelCandidates", cockpit) From 45d276a1c1da7ba6e9692023774ae9ffee4442aa Mon Sep 17 00:00:00 2001 From: sicarii Date: Sun, 20 Sep 2026 09:15:14 -0400 Subject: [PATCH 10/17] Cockpit: keep the graph generation the status names while a newer one waits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The brainstem republishes graph.json up to five times a pulse (two partial scans, the complete generation, its post-source rebuild) and names the final one in status.json about eleven seconds after the first write. The cockpit withdrew the graph the instant the file changed and, once reread, refused the newer generation because no status named it yet, so the center card said "current graph unavailable" for a quarter of every minute and the entries list flashed a red boundary once a pulse. The combined-claim contract is unchanged — the displayed graph is always the one the displayed status names — but the cockpit now remembers that graph (admittedGraph) and keeps it on screen while a newer publication waits for the status that will name it, saying so in the snapshot line. A rejected or vanished resident graph still withdraws it; a status naming neither generation withdraws it. An unnamed generation never touches the layout, so hover, neighbourhoods, a locked selection and a running replay survive the republish. One rename can arrive as a burst of change events (the probe saw the second event find `graph` already withdrawn), so both pending boundaries keep the admitted generation. The remaining beat is the status reread itself: settle waits drop from 150–200 ms to 60 ms, the returning graph and entry stream fade in over 140 ms (the shell's PopupCard timing), "current graph unavailable" is only said after 400 ms of absence, the cockpit fades in when summoned, and boundaries that name a routine revalidation render muted instead of urgent. Measured on this box with a 4 fps capture of the graph card correlated with the snapshot files' mtimes: before, 11 s blank per 60 s pulse; after, one sub-second dip at the status rename. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 27 +++++ Cockpit.qml | 125 +++++++++++++++++---- docs/MANUAL.md | 9 +- tests/test_cockpit_integrity_boundaries.py | 125 +++++++++++++++++++++ 4 files changed, 265 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 997af36..9927a7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## Unreleased + +### Cockpit + +The graph no longer vanishes for ten seconds a minute. The brainstem +republishes `graph.json` up to four times a pulse (two partial scans, then the +complete generation, then its post-source rebuild) and names the final one in +`status.json` about eleven seconds after the first write. The cockpit withdrew +the graph the moment the file changed and, once reread, refused the newer +generation because no status named it yet — so for a quarter of every minute +the center card said `current graph unavailable`. The cockpit now keeps the +generation the current status names on screen (`admittedGraph`) while a newer +publication waits for the status that will name it, and the header says so: +`graph published 42s ago · complete · newer graph publication awaiting its +status`. The combined claim is unchanged: the displayed graph is always the +one the displayed status names, and a rejected or vanished resident graph still +withdraws it. An unnamed generation never touches the layout, so hover, +neighbourhoods, and a locked selection survive the republish; a running growth +replay survives the beat between generations too. + +The remaining beat — the status reread itself — is one settle wait (60 ms, was +150–200 ms) plus a frame; the returning graph and entry stream fade in over +140 ms instead of snapping, and `current graph unavailable` is only said once +the graph has been absent for 400 ms. Boundaries that name a routine +revalidation (`… pending validation`) render in the muted text colour; an +unavailable or rejected source keeps the urgent colour. + ## 1.8.0 — 2026-09-19 · the receipts survive the move Four reporters and one migrated maintainer machine hit this release where diff --git a/Cockpit.qml b/Cockpit.qml index 48d4dd7..df0ae94 100644 --- a/Cockpit.qml +++ b/Cockpit.qml @@ -33,6 +33,14 @@ Item { property bool statusLoadValid: false property bool installCompletionResolved: false property var graph: null + // The graph generation the current status names. `graph` holds the watched + // file's latest validated bytes and is withdrawn the instant that file + // changes; `admittedGraph` is the last graph a validated status named, and + // it is displayed only while that same status is still the current status. + // The brainstem republishes graph.json several times a pulse and names the + // final one in status.json seconds later; withholding the still-named + // generation in between blanked the graph for ten seconds a minute. + property var admittedGraph: null property var thoughts: [] property bool thoughtsResolved: false property bool thoughtsLoadValid: false @@ -223,9 +231,20 @@ Item { } function currentGraphSnapshot() { - return root.graphBoundary === "" - && Model.snapshotGenerationsMatch(root.currentStatus, root.graph) - ? root.graph : null + if (root.graphBoundary === "" + && Model.snapshotGenerationsMatch(root.currentStatus, root.graph)) + return root.graph + // A newer graph publication is on disk — pending validation, or validated + // but not yet named by any status. The current status still names the + // admitted generation, so that pair remains the combined snapshot claim. + var newerPublication = root.graphBoundary === "" + ? !!root.graph + : /pending validation$/.test(root.graphBoundary) + if (newerPublication + && Model.snapshotGenerationsMatch(root.currentStatus, + root.admittedGraph)) + return root.admittedGraph + return null } function isPlainRecord(value) { @@ -480,7 +499,24 @@ Item { return parts.join(" · ") } + // A boundary that names a routine revalidation beat is context, not an + // alarm; an unavailable or rejected source keeps the urgent colour. + function boundaryColor(text) { + return /pending validation$/.test(text) + ? Qt.alpha(root.fg, 0.55) : root.urgent + } + function graphSnapshotText() { + var shown = root.currentGraph + if (shown && shown !== root.graph) { + // The admitted generation the current status names, while a newer + // graph publication waits for the status that will name it. + return "graph published " + + Model.timeAgo(shown.ts, root.nowMs) + " · " + + (shown.snapshot && shown.snapshot.complete === true + ? "complete" : "partial") + + " · newer graph publication awaiting its status" + } if (root.graphBoundary !== "") return root.graphBoundary if (!root.graph || !root.graph.ts) return "no graph snapshot" if (!root.currentStatus) @@ -2289,10 +2325,16 @@ Item { Model.syncGraph( root.currentGraph, graphCanvas.width, graphCanvas.height) if (!root.currentGraph) { + // Withdrawn: hover is re-derived from the pointer, but a locked + // selection and a running replay survive the beat between one named + // generation and the next. root.hoverId = "" - root.selectedId = "" - root.playing = false - root.revealT = 1.0 + graphGapTimer.restart() + } else { + graphGapTimer.stop() + root.graphGapSettled = false + if (root.selectedId !== "" && !root.graphHasNode(root.selectedId)) + root.selectedId = "" } graphCanvas.requestPaint() } @@ -2328,6 +2370,10 @@ Item { root.status = parsed root.statusLoadValid = true root.statusBoundary = "" + // A status that names the resident graph admits that generation. + if (root.graphBoundary === "" && root.graph + && Model.snapshotGenerationsMatch(root.status, root.graph)) + root.admittedGraph = root.graph root.stale = Model.timestampStale( parsed.ts, Date.now(), root.staleAfterSec) } catch (e) { @@ -2349,12 +2395,18 @@ Item { root.graphBoundary = root.graph || root.graphBoundary.indexOf("last good graph;") === 0 ? "last good graph; latest graph rejected" : "no valid graph snapshot" + root.admittedGraph = null return } - if (graphCanvas.width > 0 && graphCanvas.height > 0) + // Only a generation the current status names may touch the layout: + // syncing an unnamed newer publication would rebuild the rings and + // adjacency under the admitted graph still on screen. + var named = Model.snapshotGenerationsMatch(root.currentStatus, g) + if (named && graphCanvas.width > 0 && graphCanvas.height > 0) Model.syncGraph(g, graphCanvas.width, graphCanvas.height) root.graph = g root.graphBoundary = "" + if (named) root.admittedGraph = g if (root.selectedId !== "" && !root.graphHasNode(root.selectedId)) root.selectedId = "" if (root.hoverId !== "" && !root.graphHasNode(root.hoverId)) @@ -2364,6 +2416,7 @@ Item { root.graphBoundary = root.graph || root.graphBoundary.indexOf("last good graph;") === 0 ? "last good graph; latest graph rejected" : "no valid graph snapshot" + root.admittedGraph = null } } @@ -2776,8 +2829,17 @@ Item { statusApply.restart() } } - Timer { id: statusApply; interval: 150; repeat: false + // Every snapshot is published by one atomic rename, so the settle wait only + // has to outlast the watcher's own burst of events for that rename. At + // 150-200 ms the beat between one named generation and the next was a + // visible blink; the reread itself validates whatever it finds. + Timer { id: statusApply; interval: 60; repeat: false onTriggered: statusFile.reload() } + // "current graph unavailable" is said only once the graph has been absent + // long enough to be a state, not the beat between two named generations. + property bool graphGapSettled: false + Timer { id: graphGapTimer; interval: 400; repeat: false + onTriggered: root.graphGapSettled = true } FileView { id: graphFile @@ -2802,6 +2864,7 @@ Item { var hadLastGood = !!root.graph || root.graphBoundary.indexOf("last good graph;") === 0 root.graph = null + root.admittedGraph = null root.selectedId = "" root.hoverId = "" graphCanvas.requestPaint() @@ -2813,18 +2876,19 @@ Item { root.clearVerification() readyProc.cancel() root.clearReadyCheck() - root.graphBoundary = root.graph + // One rename can arrive as a burst of change events; the first already + // withdrew `graph`, but the admitted generation is still the last good + // graph on screen. + root.graphBoundary = root.graph || root.admittedGraph ? "last good graph; newer graph snapshot pending validation" : "resident graph snapshot pending validation" root.graph = null - root.selectedId = "" - root.hoverId = "" graphCanvas.requestPaint() graphFile.refreshPending = true graphApply.restart() } } - Timer { id: graphApply; interval: 200; repeat: false + Timer { id: graphApply; interval: 60; repeat: false onTriggered: graphFile.reload() } FileView { @@ -2860,7 +2924,7 @@ Item { thoughtsApply.restart() } } - Timer { id: thoughtsApply; interval: 200; repeat: false + Timer { id: thoughtsApply; interval: 60; repeat: false onTriggered: thoughtsFile.reload() } FileView { @@ -3726,6 +3790,12 @@ Item { id: keyCatcher anchors.fill: parent focus: true + // The surface arrives on the shell's own beat (PopupCard fades over + // 140 ms); a summoned cockpit that snaps in reads as a glitch. + opacity: root.cockpitVisible ? 1 : 0 + Behavior on opacity { + NumberAnimation { duration: 160; easing.type: Easing.OutCubic } + } // Sheets contain focusable controls, so Esc must remain available even // when a field rather than this catcher owns active focus. @@ -3987,9 +4057,10 @@ Item { textFormat: Text.PlainText renderType: Text.NativeRendering text: "PUBLISHED SNAPSHOT · " + root.graphSnapshotText() - color: root.graphBoundary !== "" || (root.snap - && root.snap.complete !== true) - ? root.urgent : Qt.alpha(root.fg, 0.5) + color: root.graphBoundary !== "" && !root.currentGraph + ? root.boundaryColor(root.graphBoundary) + : (root.snap && root.snap.complete !== true) + ? root.urgent : Qt.alpha(root.fg, 0.5) font.family: root.fontFamily font.pixelSize: Style.font.caption } @@ -5346,7 +5417,10 @@ Item { text: [root.graphBoundary, root.statusBoundary] .filter(function(value) { return value !== "" }).join(" · ") wrapMode: Text.WordWrap - color: root.urgent + color: [root.graphBoundary, root.statusBoundary].every( + function(value) { + return value === "" || /pending validation$/.test(value) + }) ? Qt.alpha(root.fg, 0.55) : root.urgent font.family: root.fontFamily font.pixelSize: Style.font.caption } @@ -5483,6 +5557,12 @@ Item { anchors.fill: parent anchors.margins: 2 renderStrategy: Canvas.Cooperative + // A returning generation fades in on the display's own beat; a + // withdrawn one is cleared at once by the repaint that withdrew it. + opacity: root.currentGraph ? 1 : 0 + Behavior on opacity { + NumberAnimation { duration: 140; easing.type: Easing.OutCubic } + } // The glow layer follows every graph frame, so it never shows // a halo where a node no longer is. onPainted: glowCanvas.requestPaint() @@ -5755,6 +5835,7 @@ Item { Canvas { id: glowCanvas anchors.fill: graphCanvas + opacity: graphCanvas.opacity renderStrategy: Canvas.Cooperative onPaint: { var ctx = getContext("2d") @@ -5951,7 +6032,7 @@ Item { + " of " + root.currentGraph.pages_total + " memories · " + root.currentGraph.edges.length + " links · " + (root.snap && root.snap.complete ? "complete" : "partial") - : "current graph unavailable" + : root.graphGapSettled ? "current graph unavailable" : "" color: Qt.alpha(root.fg, 0.45) font.family: root.fontFamily font.pixelSize: Style.font.caption @@ -6158,7 +6239,7 @@ Item { width: thoughtHeader.width text: root.thoughtsBoundary wrapMode: Text.WordWrap - color: root.urgent + color: root.boundaryColor(root.thoughtsBoundary) font.family: root.fontFamily font.pixelSize: Style.font.caption } @@ -6173,6 +6254,12 @@ Item { anchors.topMargin: Style.space(6) contentWidth: width contentHeight: thoughtCol.implicitHeight + // A revalidated stream fades back in; a withdrawn one empties + // at once. + opacity: root.thoughtsLoadValid ? 1 : 0 + Behavior on opacity { + NumberAnimation { duration: 140; easing.type: Easing.OutCubic } + } pixelAligned: true clip: true boundsBehavior: Flickable.StopAtBounds diff --git a/docs/MANUAL.md b/docs/MANUAL.md index cd71fed..4017dbb 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -371,8 +371,13 @@ the result responsive but legible. Nodes glow when freshly touched. gaps mark the snapshot partial in SOURCE HEALTH. If graph publication throws, the pulse exposes the error and signs `PULSE:ingest ... graph-fail` rather than reporting successful graph publication. Only a structurally exact - partial envelope remains a diagnostic graph: an open or malformed envelope, - or a status/graph publication mismatch, withdraws every current graph claim. + partial envelope remains a diagnostic graph: an open or malformed envelope + withdraws every current graph claim. The displayed graph is always the + generation the displayed status names: while a newer graph publication + waits for the status that will name it (the brainstem writes `graph.json` + seconds before `status.json`), the cockpit keeps the named generation on + screen and the snapshot line says `newer graph publication awaiting its + status`; a status naming neither generation withdraws the graph. - **The graph window is incrementally projected.** Publication advances a durable no-follow corpus directory cursor, retains only the capped cockpit candidates, and rereads only those selected pages under their observed diff --git a/tests/test_cockpit_integrity_boundaries.py b/tests/test_cockpit_integrity_boundaries.py index 91504fe..d827127 100644 --- a/tests/test_cockpit_integrity_boundaries.py +++ b/tests/test_cockpit_integrity_boundaries.py @@ -631,6 +631,131 @@ def test_snapshot_rejection_withdraws_prior_live_results(self): self.assertIn("readyProc.cancel()", block) self.assertIn("root.clearReadyCheck()", block) + def test_current_graph_gate_keeps_the_generation_the_status_names(self): + # The brainstem republishes graph.json several times a pulse and names + # the final one in status.json seconds later. A newer publication on + # disk never displaces the generation the current status still names: + # that pair is the combined snapshot claim until a status names another. + cockpit = _read("Cockpit.qml") + gate = _qml_function(cockpit, "currentGraphSnapshot") + wrapper = """ +var Model = { snapshotGenerationsMatch: snapshotGenerationsMatch } +var root = { + currentStatus: null, graph: null, graphBoundary: "", admittedGraph: null, + currentGraphSnapshot: currentGraphSnapshot +} +function shown(status, graph, boundary, admitted) { + root.currentStatus = status + root.graph = graph + root.graphBoundary = boundary + root.admittedGraph = admitted + return root.currentGraphSnapshot() +} +""" + gate + admitted = {"publication_id": "a" * 32, "nodes": [], "edges": [], + "snapshot": {"complete": True}} + newer = {"publication_id": "b" * 32, "nodes": [], "edges": [], + "snapshot": {"complete": False}} + names_admitted = {"graph_publication_id": "a" * 32} + pending = "last good graph; newer graph snapshot pending validation" + for status, graph, boundary, expected in ( + # validated newer publication, not yet named by any status + (names_admitted, newer, "", admitted), + # newer publication still pending validation + (names_admitted, None, pending, admitted), + # a burst of change events for one rename: the second event + # found `graph` already withdrawn by the first + (names_admitted, None, + "resident graph snapshot pending validation", admitted), + # the status advanced to name the newer publication + ({"graph_publication_id": "b" * 32}, newer, "", newer)): + with self.subTest(boundary=boundary, status=status): + self.assertEqual(self._run( + wrapper, "shown", [status, graph, boundary, admitted], + sources=("Model.js",)), expected) + for status, graph, boundary, retained in ( + # a rejected or vanished resident graph withdraws the claim + (names_admitted, newer, + "last good graph; latest graph rejected", admitted), + (names_admitted, None, + "last good graph; resident graph snapshot unavailable", + admitted), + # a status naming neither generation, or no status at all + ({"graph_publication_id": "c" * 32}, newer, "", admitted), + (None, newer, "", admitted), + ({"graph_publication_id": ""}, newer, "", admitted), + # nothing was ever admitted + (names_admitted, newer, "", None), + (names_admitted, None, pending, None)): + with self.subTest(boundary=boundary, status=status, + retained=retained): + self.assertIsNone(self._run( + wrapper, "shown", [status, graph, boundary, retained], + sources=("Model.js",))) + + def test_only_a_named_generation_is_admitted_or_touches_the_layout(self): + cockpit = _read("Cockpit.qml") + apply_graph = _qml_function(cockpit, "applyGraph") + # The layout singleton (rings, adjacency, tsNorm) belongs to the + # generation on screen; an unnamed newer publication must not rebuild + # it underneath the admitted graph. + self.assertIn( + "var named = Model.snapshotGenerationsMatch(root.currentStatus, g)", + apply_graph) + self.assertIn("if (named && graphCanvas.width > 0", apply_graph) + self.assertIn("if (named) root.admittedGraph = g", apply_graph) + rejection = apply_graph[:apply_graph.index("var named")] + self.assertIn("root.admittedGraph = null", rejection) + self.assertIn( + "root.admittedGraph = null", + apply_graph[apply_graph.index("catch (e)"):]) + apply_status = _qml_function(cockpit, "applyStatus") + self.assertIn("root.admittedGraph = root.graph", apply_status) + self.assertIn( + "Model.snapshotGenerationsMatch(root.status, root.graph)", + apply_status) + graph_file = _qml_element(cockpit, "id: graphFile") + failed = graph_file[graph_file.index("onLoadFailed:"): + graph_file.index("onFileChanged:")] + self.assertIn("root.admittedGraph = null", failed) + changed = graph_file[graph_file.index("onFileChanged:"):] + self.assertNotIn("root.admittedGraph =", changed) + self.assertIn("root.graph || root.admittedGraph", changed) + # The admitted generation stays on screen through the change, so a + # locked selection survives it. + self.assertNotIn("root.selectedId", changed) + summary = _qml_function(cockpit, "graphSnapshotText") + self.assertIn("shown !== root.graph", summary) + self.assertIn("newer graph publication awaiting its status", summary) + + def test_snapshot_settle_waits_are_one_beat(self): + cockpit = _read("Cockpit.qml") + for timer_id in ("id: statusApply", "id: graphApply", + "id: thoughtsApply"): + with self.subTest(timer=timer_id): + self.assertIn("interval: 60", _qml_element(cockpit, timer_id)) + + def test_revalidation_boundaries_are_context_not_alarms(self): + cockpit = _read("Cockpit.qml") + tone = _qml_function(cockpit, "boundaryColor") + self.assertIn("/pending validation$/.test(text)", tone) + self.assertIn("root.urgent", tone) + self.assertIn("color: root.boundaryColor(root.thoughtsBoundary)", + cockpit) + self.assertIn("root.boundaryColor(root.graphBoundary)", cockpit) + for boundary in ( + "last good graph; newer graph snapshot pending validation", + "last good status; newer resident status pending validation", + "last good generated-entry stream; newer stream pending validation", + "last good continuity status; newer update pending validation"): + self.assertIn(boundary, cockpit) + self.assertTrue(boundary.endswith("pending validation")) + for boundary in ( + "last good graph; latest graph rejected", + "resident graph snapshot unavailable", + "resident status unavailable"): + self.assertFalse(boundary.endswith("pending validation")) + if __name__ == "__main__": unittest.main() From c30e9b01af318bf99e1770dcbd9c159d340ef92d Mon Sep 17 00:00:00 2001 From: sicarii Date: Sun, 20 Sep 2026 09:19:15 -0400 Subject: [PATCH 11/17] Changelog: the graph blank was eleven seconds of every sixty, not a quarter Measured 11.1 s per 60 s pulse (JACKAL exact: 11112/1000/60 = 463/2500). Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9927a7b..0be69e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ republishes `graph.json` up to four times a pulse (two partial scans, then the complete generation, then its post-source rebuild) and names the final one in `status.json` about eleven seconds after the first write. The cockpit withdrew the graph the moment the file changed and, once reread, refused the newer -generation because no status named it yet — so for a quarter of every minute +generation because no status named it yet — so for about eleven seconds of every sixty the center card said `current graph unavailable`. The cockpit now keeps the generation the current status names on screen (`admittedGraph`) while a newer publication waits for the status that will name it, and the header says so: From 64dccf889b25a8f77fdf9979abc39a1284e387fc Mon Sep 17 00:00:00 2001 From: sicarii Date: Sun, 20 Sep 2026 12:13:41 -0400 Subject: [PATCH 12/17] fix(cockpit): smooth presentation and reduce idle graph work --- CHANGELOG.md | 14 +- Cockpit.qml | 193 +++++++++++++++---------- docs/MANUAL.md | 17 +++ tests/test_cockpit_boundary_horizon.py | 4 +- tests/test_marketplace_first_light.py | 8 +- 5 files changed, 155 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0be69e1..14aa522 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ ### Cockpit +Opening no longer stacks a full-screen Qt fade on the desktop animation. +Snapshot refresh and graph motion wait until arrival, and the graph image is +painted before presentation to avoid exposing uninitialized pixels on remap. +Native Qt rings replace the full-size transparent glow canvas; inspecting a +settled node no longer keeps the layout simulation running. Routine status +refreshes use the existing cockpit instead of flashing the first-install gate; +snapshot admission and action guards remain unchanged. Status counts guard both +the status and graph snapshots. Backup schedule details expand on demand, and +generated entries use more readable prose typography and wrapped origin labels. + The graph no longer vanishes for ten seconds a minute. The brainstem republishes `graph.json` up to four times a pulse (two partial scans, then the complete generation, then its post-source rebuild) and names the final one in @@ -21,8 +31,8 @@ neighbourhoods, and a locked selection survive the republish; a running growth replay survives the beat between generations too. The remaining beat — the status reread itself — is one settle wait (60 ms, was -150–200 ms) plus a frame; the returning graph and entry stream fade in over -140 ms instead of snapping, and `current graph unavailable` is only said once +150–200 ms) plus a frame; the entry stream fades in over 140 ms, while the graph +paints directly without a full-image fade. `current graph unavailable` is only said once the graph has been absent for 400 ms. Boundaries that name a routine revalidation (`… pending validation`) render in the muted text colour; an unavailable or rejected source keeps the urgent colour. diff --git a/Cockpit.qml b/Cockpit.qml index df0ae94..f60f423 100644 --- a/Cockpit.qml +++ b/Cockpit.qml @@ -56,8 +56,10 @@ Item { // it sets it, and the frame loop clears it once the layout has settled. property bool layoutLive: true onPlayingChanged: layoutLive = true - onHoverIdChanged: layoutLive = true - onHiddenKindsChanged: layoutLive = true + // Inspection changes ink, not node positions. A stationary pointer must + // not keep the expensive graph/label canvas painting every display frame. + onEffIdChanged: graphCanvas.requestPaint() + onHiddenKindsChanged: graphCanvas.requestPaint() property string verifyMsg: "" property bool verifyOk: false property string graphBoundary: "" @@ -72,6 +74,7 @@ Item { property var continuitySchedule: null property string continuityScheduleBoundary: "" property bool continuitySheetOpen: false + property bool continuityExpanded: false property string continuityPage: "overview" property bool restoreConfirmOpen: false property string continuityActionMsg: "" @@ -141,6 +144,12 @@ Item { : Model.guidedLifecycle(root.runtimeEvidence, root.installCompletion, root.pluginVersion) readonly property bool setupRequired: root.releaseLifecycle !== "ready" + // Remember presentation history only; this never admits data or actions. + // A watched-file refresh should show CHECKING in the existing cockpit, + // not replace an established session with the first-install screen. + property bool lifecycleWasReady: false + readonly property bool showSetupGate: root.setupRequired + && !(root.lifecycleWasReady && root.releaseLifecycle === "checking") // A caveat on the wording, never a lifecycle. It cannot reach // releaseLifecycle, so no timeout can move this gate to ready, and it // asserts nothing about the installer beyond what SIA has observed. @@ -200,6 +209,22 @@ Item { && root.focusedWorkspaceName !== root.workspaceLockName readonly property bool cockpitVisible: root.opened && !root.workspaceLockMismatch + // Let the compositor move the finished surface. Repainting a full-screen + // opacity/translation every frame stalls the software Qt renderer. + Timer { + id: presentationHold + interval: 450 + onTriggered: { + // The keepLoaded file watchers already withdraw changed publications. + // Refresh again after arrival instead of parsing every snapshot while + // the first surface is being painted. + statusFile.reload(); installCompletionFile.reload() + graphFile.reload(); thoughtsFile.reload() + continuityFile.reload() + continuityScheduleRefresh.restart() + graphCanvas.requestPaint() + } + } readonly property bool continuityStale: Model.continuityStale(root.continuity, root.nowMs, Model.continuityStaleAfterSec()) @@ -2262,15 +2287,9 @@ Item { // age on this screen, the installing horizon included, is measured // against it; re-read it before any of them are painted. nowMs = Date.now() - // Opening requests fresh bytes without discarding the last validated - // generation. Cold startup and every failed load still resolve fail-closed. - statusFile.reload(); installCompletionFile.reload() - graphFile.reload(); thoughtsFile.reload() - continuityFile.reload() - continuityScheduleRefresh.restart() - if (root.currentGraph && graphCanvas.width > 0 && graphCanvas.height > 0) - Model.syncGraph( - root.currentGraph, graphCanvas.width, graphCanvas.height) + // Watched publications and canvas resize handlers maintain the layout + // while closed. The presentation timer requests a fresh read on arrival. + presentationHold.restart() Qt.callLater(function() { if (payload.mode === "continuity") root.openContinuity("overview") else if (root.cockpitVisible && root.setupActionAllowed) @@ -2309,7 +2328,11 @@ Item { } onCockpitVisibleChanged: { - if (!root.cockpitVisible) return + if (!root.cockpitVisible) { + presentationHold.stop() + return + } + presentationHold.restart() Qt.callLater(function() { if (!root.cockpitVisible) return if (root.setupActionAllowed) firstLightButton.forceActiveFocus() @@ -2340,6 +2363,7 @@ Item { } onReleaseLifecycleChanged: { + if (root.releaseLifecycle === "ready") root.lifecycleWasReady = true // Ahead of the visibility guard on purpose. The horizon has to keep // running while the cockpit is closed, which is where an installer // usually dies. @@ -3779,7 +3803,7 @@ Item { id: win visible: root.cockpitVisible anchors { top: true; bottom: true; left: true; right: true } - color: Color.background + color: root.bg exclusionMode: ExclusionMode.Ignore WlrLayershell.namespace: "sia-cockpit" WlrLayershell.layer: WlrLayer.Overlay @@ -3790,11 +3814,11 @@ Item { id: keyCatcher anchors.fill: parent focus: true - // The surface arrives on the shell's own beat (PopupCard fades over - // 140 ms); a summoned cockpit that snaps in reads as a glitch. - opacity: root.cockpitVisible ? 1 : 0 - Behavior on opacity { - NumberAnimation { duration: 160; easing.type: Easing.OutCubic } + enabled: root.cockpitVisible + Rectangle { + anchors.fill: parent + color: root.bg + z: -1 } // Sheets contain focusable controls, so Esc must remain available even @@ -4346,6 +4370,7 @@ Item { textFormat: Text.PlainText renderType: Text.NativeRendering text: root.continuityWeeklyText() + visible: root.continuityExpanded wrapMode: Text.WordWrap color: Qt.alpha(root.fg, 0.56) font.family: root.fontFamily @@ -4356,6 +4381,7 @@ Item { textFormat: Text.PlainText renderType: Text.NativeRendering text: root.continuitySleepText() + visible: root.continuityExpanded wrapMode: Text.WordWrap color: Qt.alpha(root.fg, 0.48) font.family: root.fontFamily @@ -4367,6 +4393,7 @@ Item { textFormat: Text.PlainText renderType: Text.NativeRendering text: root.continuityRepositoryText() + visible: root.continuityExpanded elide: Text.ElideMiddle color: Qt.alpha(root.fg, 0.7) font.family: root.fontFamily @@ -4377,6 +4404,7 @@ Item { textFormat: Text.PlainText renderType: Text.NativeRendering text: root.continuityLatestText() + visible: root.continuityExpanded wrapMode: Text.WordWrap color: Qt.alpha(root.fg, 0.52) font.family: root.fontFamily @@ -4398,6 +4426,14 @@ Item { font.pixelSize: Style.font.caption } + Ui.Button { + text: root.continuityExpanded ? "Less detail ▴" : "Schedule & recovery details ▾" + fontSize: Style.font.caption + focusable: true + Accessible.name: text + onClicked: root.continuityExpanded = !root.continuityExpanded + } + Row { spacing: Style.spacing.sm Ui.Button { @@ -4527,12 +4563,12 @@ Item { rowSpacing: Style.space(2) Text { textFormat: Text.PlainText; renderType: Text.NativeRendering; text: "memories"; color: Qt.alpha(root.fg, 0.55) font.family: root.fontFamily; font.pixelSize: Style.font.bodySmall } - Text { textFormat: Text.PlainText; renderType: Text.NativeRendering; text: root.currentGraph ? String(root.currentStatus.pages) : "—" + Text { textFormat: Text.PlainText; renderType: Text.NativeRendering; text: root.currentStatus && root.currentGraph ? String(root.currentStatus.pages) : "—" color: root.fg; font.bold: true font.family: root.fontFamily; font.pixelSize: Style.font.bodySmall } Text { textFormat: Text.PlainText; renderType: Text.NativeRendering; text: "links"; color: Qt.alpha(root.fg, 0.55) font.family: root.fontFamily; font.pixelSize: Style.font.bodySmall } - Text { textFormat: Text.PlainText; renderType: Text.NativeRendering; text: root.currentGraph ? String(root.currentStatus.graph_edges) : "—" + Text { textFormat: Text.PlainText; renderType: Text.NativeRendering; text: root.currentStatus && root.currentGraph ? String(root.currentStatus.graph_edges) : "—" color: root.fg; font.bold: true font.family: root.fontFamily; font.pixelSize: Style.font.bodySmall } Text { textFormat: Text.PlainText; renderType: Text.NativeRendering; text: "events today"; color: Qt.alpha(root.fg, 0.55) @@ -5547,7 +5583,7 @@ Item { anchors.leftMargin: body.gap anchors.rightMargin: body.gap radius: Style.cornerRadius - color: Qt.alpha(root.fg, 0.03) + color: Qt.tint(root.bg, Qt.alpha(root.fg, 0.03)) border.color: Qt.alpha(root.fg, 0.10) border.width: 1 clip: true @@ -5556,13 +5592,10 @@ Item { id: graphCanvas anchors.fill: parent anchors.margins: 2 - renderStrategy: Canvas.Cooperative - // A returning generation fades in on the display's own beat; a - // withdrawn one is cleared at once by the repaint that withdrew it. + renderStrategy: Canvas.Immediate + // Finish the image before presenting the surface. Cooperative + // painting exposed an unpainted texture during remapping here. opacity: root.currentGraph ? 1 : 0 - Behavior on opacity { - NumberAnimation { duration: 140; easing.type: Easing.OutCubic } - } // The glow layer follows every graph frame, so it never shows // a halo where a node no longer is. onPainted: glowCanvas.requestPaint() @@ -5586,7 +5619,8 @@ Item { // and every frame landed off the display's beat. FrameAnimation { id: graphFrames - running: root.opened && root.currentGraph !== null + running: root.cockpitVisible && !presentationHold.running + && root.currentGraph !== null && root.layoutLive onTriggered: { // A slow frame advances the layout by the time it took, up @@ -5606,7 +5640,7 @@ Item { graphCanvas.width, graphCanvas.height, root.revealT, ms) graphCanvas.requestPaint() - if (!root.playing && root.hoverId === "" && Model.settled()) + if (!root.playing && Model.settled()) root.layoutLive = false } } @@ -5621,7 +5655,8 @@ Item { id: graphBreath interval: 200 repeat: true - running: root.opened && root.currentGraph !== null + running: root.cockpitVisible && !presentationHold.running + && root.currentGraph !== null && !root.layoutLive onTriggered: { Model.breathe(interval) @@ -5633,6 +5668,8 @@ Item { var ctx = getContext("2d") ctx.reset() ctx.clearRect(0, 0, width, height) + ctx.fillStyle = graphCard.color + ctx.fillRect(0, 0, width, height) var graph = root.currentGraph if (!graph || !graph.nodes) return var now = root.nowMs > 0 ? root.nowMs : Date.now() @@ -5828,53 +5865,56 @@ Item { } } - // The breath layer: fresh memories glow and the root keeps its - // halo. A settled graph repaints nothing else, so breathing costs - // a handful of arcs, not the whole graph. It does not take the - // pointer; the graph's own MouseArea beneath it keeps hover. - Canvas { + // Native rings avoid uploading a full transparent canvas for a + // small halo. They do not intercept graph inspection underneath. + Item { id: glowCanvas anchors.fill: graphCanvas opacity: graphCanvas.opacity - renderStrategy: Canvas.Cooperative - onPaint: { - var ctx = getContext("2d") - ctx.reset() - ctx.clearRect(0, 0, width, height) + property var rings: [] + function requestPaint() { + var next = [] var graph = root.currentGraph - if (!graph || !graph.nodes) return - var now = root.nowMs > 0 ? root.nowMs : Date.now() - var eff = root.effId - var nbrs = eff !== "" ? Model.neighbors(eff) : null - var nodes = graph.nodes - for (var i = 0; i < nodes.length; i++) { - var n = nodes[i] - if (!root.nodeVisible(n)) continue - var p = Model.posOf(n.id) - if (!p) continue - if (eff !== "" && n.id !== eff - && !Model.hasNeighbor(nbrs, n.id)) continue - var r = Model.nodeRadius(n) - var fresh = Model.freshness(n, now) - if (fresh > 0.02) { - var breathe = 0.75 + 0.25 * Math.sin(Model.phase() * 2 - + p.x * 0.05) - ctx.fillStyle = Qt.alpha(root.accent, 0.28 * fresh * breathe) - // A ring, not a disc: the node's own fill stays untinted. - ctx.beginPath() - ctx.arc(p.x, p.y, r + 5 + 4 * fresh, 0, 2 * Math.PI) - ctx.arc(p.x, p.y, r, 0, 2 * Math.PI, true) - ctx.fill() - } - if (n.id === "sia/cortex") { - var halo = 0.35 + 0.20 * Math.sin(Model.phase()) - ctx.strokeStyle = Qt.alpha(root.fg, halo) - ctx.lineWidth = 1.2 - ctx.beginPath() - ctx.arc(p.x, p.y, r + 3.5, 0, 2 * Math.PI) - ctx.stroke() + if (graph && graph.nodes) { + var eff = root.effId + var nbrs = eff !== "" ? Model.neighbors(eff) : null + for (var i = 0; i < graph.nodes.length; i++) { + var n = graph.nodes[i] + var p = Model.posOf(n.id) + if (!p || !root.nodeVisible(n)) continue + if (eff !== "" && n.id !== eff + && !Model.hasNeighbor(nbrs, n.id)) continue + var r = Model.nodeRadius(n) + var fresh = Model.freshness(n, root.nowMs) + if (fresh > 0.02) { + var breathe = 0.75 + 0.25 * Math.sin(Model.phase() * 2 + + p.x * 0.05) + next.push({ x: p.x, y: p.y, radius: r + 5 + 4 * fresh, + thickness: 5 + 4 * fresh, + ink: Qt.alpha(root.accent, 0.28 * fresh * breathe) }) + } + if (n.id === "sia/cortex") + next.push({ x: p.x, y: p.y, radius: r + 3.5, + thickness: 1.2, + ink: Qt.alpha(root.fg, 0.35 + 0.20 * Math.sin(Model.phase())) }) } } + rings = next + } + Repeater { + model: glowCanvas.rings + delegate: Rectangle { + required property var modelData + x: modelData.x - modelData.radius + y: modelData.y - modelData.radius + width: modelData.radius * 2 + height: width + radius: modelData.radius + color: "transparent" + border.width: modelData.thickness + border.color: modelData.ink + antialiasing: true + } } } @@ -6301,19 +6341,22 @@ Item { width: parent.width text: thoughtRow.modelData.text wrapMode: Text.WordWrap + lineHeight: 1.2 color: thoughtRow.urgencyState === "unrecorded" ? Qt.alpha(root.fg, 0.6) : thoughtRow.urgencyState === "urgent" ? root.urgent : Qt.alpha(root.fg, 0.85) - font.family: root.fontFamily - font.pixelSize: Style.font.caption + font.family: "sans-serif" + font.pixelSize: Style.font.bodySmall } Text { textFormat: Text.PlainText renderType: Text.NativeRendering text: root.thoughtRowMetadata( thoughtRow.modelData, root.nowMs) - color: Qt.alpha(root.fg, 0.35) + width: parent.width + wrapMode: Text.WordWrap + color: Qt.alpha(root.fg, 0.58) font.family: root.fontFamily font.pixelSize: Style.font.caption } @@ -6332,7 +6375,7 @@ Item { Rectangle { id: firstLightGate anchors.fill: parent - visible: root.setupRequired + visible: root.showSetupGate z: 30 color: Color.background diff --git a/docs/MANUAL.md b/docs/MANUAL.md index 4017dbb..1d67800 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -262,6 +262,23 @@ activating its controller are separate deployment steps. ## 2. The cockpit +The cockpit leaves entrance and dismissal motion to the compositor, avoiding +a second full-screen fade in Qt. For a straight-down entrance on Omarchy's Lua +Hyprland configuration, add this scoped rule to `~/.config/hypr/looknfeel.lua`: + +```lua +hl.layer_rule({ + name = "sia-cockpit-presentation", + match = { namespace = "^sia-cockpit$" }, + animation = "slide top", +}) +``` + +Reload with `hyprctl reload` and check `hyprctl configerrors`. This only changes +SIA's direction; desktop animation timing and reduced-motion preferences still +apply. The continuity card's **Schedule & recovery details** control expands +routine backup information without hiding warnings or recovery actions. + Summoned from the bar (or with SUPER+SHIFT+B after an install using `SIA_INSTALL_KEYBINDING=1`); leaves with **Esc**, ✕, or `omarchy-shell shell hide khephri.sia`. diff --git a/tests/test_cockpit_boundary_horizon.py b/tests/test_cockpit_boundary_horizon.py index 9038c5d..742ddef 100644 --- a/tests/test_cockpit_boundary_horizon.py +++ b/tests/test_cockpit_boundary_horizon.py @@ -667,10 +667,10 @@ def test_stamped_status_publication_ids_match_the_backend_envelope(self): def test_vitals_withdraw_graph_counts_without_a_graph_generation(self): cockpit = " ".join(_read("Cockpit.qml").split()) self.assertIn( - 'root.currentGraph ? String(root.currentStatus.pages) : "—"', + 'root.currentStatus && root.currentGraph ? String(root.currentStatus.pages) : "—"', cockpit) self.assertIn( - 'root.currentGraph ? String(root.currentStatus.graph_edges) : "—"', + 'root.currentStatus && root.currentGraph ? String(root.currentStatus.graph_edges) : "—"', cockpit) frozen = self._snapshot() diff --git a/tests/test_marketplace_first_light.py b/tests/test_marketplace_first_light.py index 119675b..f2e20fd 100644 --- a/tests/test_marketplace_first_light.py +++ b/tests/test_marketplace_first_light.py @@ -379,8 +379,12 @@ def test_ready_generation_stays_visible_during_file_refresh(self): cockpit = _read("Cockpit.qml") open_body = _balanced_body(cockpit, "function open(payloadJson)") - self.assertIn("statusFile.reload()", open_body) - self.assertIn("installCompletionFile.reload()", open_body) + self.assertIn("presentationHold.restart()", open_body) + refresh_body = _balanced_body(cockpit, "id: presentationHold") + self.assertIn("statusFile.reload()", refresh_body) + self.assertIn("installCompletionFile.reload()", refresh_body) + self.assertNotIn("statusResolved = false", refresh_body) + self.assertNotIn("installCompletionResolved = false", refresh_body) self.assertNotIn("statusResolved = false", open_body) self.assertNotIn("installCompletionResolved = false", open_body) self.assertIn("function loadedReleaseVersion(ignored)", cockpit) From 249a7b26b3743f57a3940b86156b4831c295352b Mon Sep 17 00:00:00 2001 From: sicarii Date: Sun, 20 Sep 2026 13:28:04 -0400 Subject: [PATCH 13/17] fix(cockpit): make dated commitments reviewable and actionable --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 8 ++ Cockpit.qml | 81 ++++++++++++++++---- Model.js | 2 +- bin/sia | 19 ++++- bin/sia-intent-review | 85 +++++++++++++++++++++ bin/sialib.py | 4 +- docs/MANUAL.md | 11 +++ install.sh | 2 +- tests/test_cockpit_boundary_horizon.py | 2 +- tests/test_intent_review.py | 101 +++++++++++++++++++++++++ tests/test_pulse_sync.py | 2 +- 12 files changed, 295 insertions(+), 24 deletions(-) create mode 100644 bin/sia-intent-review create mode 100644 tests/test_intent_review.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e571ce9..090beeb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: python -m pip install --only-binary=:all: --require-hashes \ -r "$RUNNER_TEMP/sia-ci-requirements.txt" - name: compile all python - run: python3 -m py_compile bin/*.py bin/sia bin/sia-brainstem bin/sia-ledger bin/sia-mcp + run: python3 -m py_compile bin/*.py bin/sia bin/sia-brainstem bin/sia-ledger bin/sia-mcp bin/sia-intent-review - name: shell syntax run: bash -n install.sh uninstall.sh bin/sia-setup - name: release artifact contracts diff --git a/CHANGELOG.md b/CHANGELOG.md index 14aa522..d11569d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ ### Cockpit +Commitments now show their full task and calendar due date, explain that overdue +is a review reminder, and offer **Review commitment…**. The terminal review reads +the current open task through the resident CLI and requires a written outcome +and explicit confirmation before closing it. Cancelling leaves it open; stale +or failed requests never claim completion. `sia intend --show ` exposes +the full open task as JSON without internal storage paths. Review does not hold +a corpus lock while waiting for the operator and never executes task prose. + Opening no longer stacks a full-screen Qt fade on the desktop animation. Snapshot refresh and graph motion wait until arrival, and the graph image is painted before presentation to avoid exposing uninitialized pixels on remap. diff --git a/Cockpit.qml b/Cockpit.qml index f60f423..43c6a85 100644 --- a/Cockpit.qml +++ b/Cockpit.qml @@ -75,6 +75,7 @@ Item { property string continuityScheduleBoundary: "" property bool continuitySheetOpen: false property bool continuityExpanded: false + property string intentReviewFeedback: "" property string continuityPage: "overview" property bool restoreConfirmOpen: false property string continuityActionMsg: "" @@ -2104,6 +2105,20 @@ Item { root.setWorkspaceLock(root.focusedWorkspaceName) } + function reviewIntent(iid) { + if (root.setupRequired || !root.currentStatus + || !/^[0-9a-f]{10}$/.test(iid)) return + var rows = root.currentStatus.intents || [] + if (!rows.some(function(row) { return row.id === iid })) return + root.intentReviewFeedback = "Review terminal requested. If it did not open, check your desktop terminal launcher. No commitment is closed by this button." + // Only an admitted identity becomes an argument. Task prose is never code. + Quickshell.execDetached([ + "/usr/bin/env", "-u", "BASH_ENV", "-u", "ENV", + "omarchy-launch-terminal", "/usr/bin/python3", "-I", + root.pluginRoot + "/bin/sia-intent-review", iid]) + root.close() + } + function launchSetup() { // This is the sole UI launch edge. It is reached only from an explicit // click/key action; loading or enabling the plugin never executes setup. @@ -5320,26 +5335,62 @@ Item { font.pixelSize: Style.font.caption font.bold: true } + Text { + textFormat: Text.PlainText + width: parent.width + wrapMode: Text.WordWrap + text: "Past due means a task needs review, not that SIA is broken. Review the records, then record an outcome to close it." + color: Qt.alpha(root.fg, 0.6) + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + Text { + visible: root.intentReviewFeedback !== "" + textFormat: Text.PlainText + width: parent.width + wrapMode: Text.WordWrap + text: root.intentReviewFeedback + color: Qt.alpha(root.fg, 0.7) + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } Repeater { model: root.currentStatus && root.currentStatus.intents ? root.currentStatus.intents : [] - delegate: Text { + delegate: Column { required property var modelData - textFormat: Text.PlainText - renderType: Text.NativeRendering width: intentCol.width - wrapMode: Text.WordWrap - text: (modelData.days_left < 0 - ? "➤ OVERDUE " + (-modelData.days_left) + "d — " - : modelData.days_left === 0 - ? "➤ due today — " - : "➤ in " + modelData.days_left + "d — ") - + modelData.text - color: modelData.days_left < 0 ? root.urgent - : modelData.days_left <= 2 ? root.accent - : Qt.alpha(root.fg, 0.7) - font.family: root.fontFamily - font.pixelSize: Style.font.caption + spacing: Style.space(4) + Text { + textFormat: Text.PlainText + renderType: Text.NativeRendering + width: parent.width + wrapMode: Text.WordWrap + text: modelData.text + color: Qt.alpha(root.fg, 0.85) + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + Text { + textFormat: Text.PlainText + width: parent.width + wrapMode: Text.WordWrap + text: "Due " + modelData.due + (modelData.days_left < 0 + ? " · overdue — review needed" + : modelData.days_left === 0 ? " · today" : "") + color: modelData.days_left < 0 ? root.urgent : root.accent + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + Ui.Button { + text: "Review commitment…" + fontSize: Style.font.caption + focusable: true + enabled: !!root.currentStatus && !root.setupRequired + Accessible.name: "Review commitment due " + modelData.due + Accessible.description: "Opens the full task in a terminal. Closing requires an outcome and your confirmation." + onClicked: root.reviewIntent(modelData.id) + } } } } diff --git a/Model.js b/Model.js index 4d1421a..402d8b3 100644 --- a/Model.js +++ b/Model.js @@ -461,7 +461,7 @@ function residentIntentShape(intents) { var row = intents[i] if (!recordHasExactly(row, ["id", "text", "due", "days_left"]) || typeof row.id !== "string" || !/^[0-9a-f]{10}$/.test(row.id) - || !redactionFreeStatusString(row.text, 70, true) + || !redactionFreeStatusString(row.text, 300, true) || !validCalendarDate(row.due) || !integerNumber(row.days_left)) return false } diff --git a/bin/sia b/bin/sia index e70ec02..1c0e699 100755 --- a/bin/sia +++ b/bin/sia @@ -42,7 +42,8 @@ biological brain and does not establish cognition or neuroscience. sia takes list predictions and their status sia intend "task" --by YYYY-MM-DD dated commitment that SIA surfaces - as its deadline nears (--list · --done [note]); + as its deadline nears (--list · --show + · --done [note]); -- ends options here too sia grade [id] grade due predictions now (configured judge, deterministic Brier); with id, grade that one @@ -2549,6 +2550,20 @@ def cmd_ledger(): def cmd_intend(argv): """Manage dated commitments that SIA surfaces near their deadlines.""" import siatakes + if argv and argv[0] == "--show": + if len(argv) != 2 or re.fullmatch(r"[0-9a-f]{10}", argv[1]) is None: + print("usage: sia intend --show ") + return 2 + matches = [it for it in siatakes.open_intents() + if it["id"] == argv[1]] + if len(matches) != 1: + print("No unique open commitment matches that id.") + return 1 + it = matches[0] + print(json.dumps({key: it[key] for key in + ("id", "text", "due", "holder", "created")}, + ensure_ascii=True)) + return 0 if argv and argv[0] == "--history": cursor = argv[2] if len(argv) >= 3 and argv[1] == "--cursor" else None if len(argv) not in (1, 3) or len(argv) == 3 and argv[1] != "--cursor": @@ -2571,7 +2586,7 @@ def cmd_intend(argv): d = it["days_left"] when = (f"OVERDUE {-d}d" if d < 0 else "due today" if d == 0 else f"due in {d}d") - print(f" {it['id'][:6]} [{when:>11}] " + print(f" {it['id']} [{when:>11}] " f"{sialib.clip(it['text'], 70)}") return 0 if argv[0] == "--done": diff --git a/bin/sia-intent-review b/bin/sia-intent-review new file mode 100644 index 0000000..63823fd --- /dev/null +++ b/bin/sia-intent-review @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Terminal review UI; all reads/writes pass through the resident SIA CLI. + +No corpus lease is held while the operator considers a commitment. The final +CLI call re-admits readiness and closes only a still-open, exact identity. +""" +import json +import os +import re +import subprocess +import sys + + +def display(text): + # Stored prose must never become terminal control sequences. + return "".join(c if c.isprintable() else " " for c in str(text)) + + +def run_cli(args): + return subprocess.run( + [os.path.expanduser("~/.local/bin/sia"), "intend", *args], + capture_output=True, text=True, timeout=120, check=False) + + +def review(iid): + result = run_cli(["--show", iid]) + if result.returncode: + print("Unable to read this commitment. It may be closed, or SIA may need an update or recovery.") + print(display(result.stdout or result.stderr)) + return 1 + row = json.loads(result.stdout) + if (not isinstance(row, dict) or row.get("id") != iid + or not all(isinstance(row.get(k), str) + for k in ("text", "due", "holder", "created"))): + raise ValueError("Commitment response did not match the request") + print("\nREVIEW COMMITMENT\n") + print(display(row["text"])) + print("\nDue: " + display(row["due"]) + " · Holder: " + display(row["holder"])) + print("Recorded: " + display(row["created"])) + print("\nA passed due date is a reminder, not a system failure.") + print("Review the relevant records before closing this task.") + print("Closing records your outcome; it does not verify a prediction or repair a problem.") + if input("\nType done to record completion, or press Enter to leave open: ").strip() != "done": + print("Left open. Nothing changed.") + return 0 + note = input("Outcome or supporting record (required, at most 200 characters): ").strip() + if not note or len(note) > 200 or any(not c.isprintable() for c in note): + print("A printable outcome of at most 200 characters is required. Left open.") + return 2 + print("\nOutcome: " + display(note)) + if input("Type close to save this outcome and close the commitment: ").strip() != "close": + print("Left open. Nothing changed.") + return 0 + result = run_cli(["--done", iid, note]) + print(display(result.stdout or result.stderr)) + if result.returncode == 0: + print("Closed. The cockpit updates after SIA's next successful publication.") + return result.returncode + + +def main(argv): + if len(argv) != 1 or re.fullmatch(r"[0-9a-f]{10}", argv[0]) is None: + print("usage: sia-intent-review ") + return 2 + if not sys.stdin.isatty(): + print("Review requires an interactive terminal; nothing changed.") + return 2 + try: + return review(argv[0]) + except (EOFError, KeyboardInterrupt): + print("\nReview interrupted. No completion is claimed; check SIA before retrying.") + return 1 + except (OSError, ValueError, subprocess.SubprocessError) as exc: + print("Review stopped: " + display(exc)) + print("No completion is claimed. Check SIA before retrying.") + return 1 + finally: + try: + input("\nPress Enter to return to the desktop.") + except (EOFError, KeyboardInterrupt): + pass + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/bin/sialib.py b/bin/sialib.py index ae00e82..4d1758b 100644 --- a/bin/sialib.py +++ b/bin/sialib.py @@ -8710,7 +8710,7 @@ def _status_intents_shape(value): or not isinstance(row.get("id"), str) \ or re.fullmatch(r"[0-9a-f]{10}", row["id"]) is None \ or not _status_display_string( - row.get("text"), nonempty=True, limit=70) \ + row.get("text"), nonempty=True, limit=300) \ or not _status_calendar_date(row.get("due")) \ or isinstance(row.get("days_left"), bool) \ or not isinstance(row.get("days_left"), int) \ @@ -10408,7 +10408,7 @@ def _pulse_transaction_guarded( "pinned": memory_state.get("pinned", 0)}, "takes": takes_sum, "intents": [{"id": it.get("id", "?"), - "text": clip(it.get("text", ""), 70), + "text": clip(it.get("text", ""), 300), "due": it.get("due", ""), "days_left": it.get("days_left", 0)} for it in intents_open[:MAX_STATUS_INTENTS] diff --git a/docs/MANUAL.md b/docs/MANUAL.md index 1d67800..df9073b 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -262,6 +262,17 @@ activating its controller are separate deployment steps. ## 2. The cockpit +**Commitments:** a past due date means a task still needs review; it does not +mean SIA is broken. **Review commitment…** opens the full task, holder, and due +date in a terminal and closes the cockpit so the terminal is accessible. Check +the relevant records first. To finish, type `done`, enter a short outcome or +supporting record, then type `close`. Enter at either confirmation leaves the +task open. Closing records your outcome; it does not verify predictions or +repair a system problem. The reminder clears after the next successful SIA +publication. If the terminal cannot launch, use `sia intend --list` and +`sia intend --show `; the latter reads the full open task as JSON. +Existing installations need the matching updated runtime for this review flow. + The cockpit leaves entrance and dismissal motion to the compositor, avoiding a second full-screen fade in Qt. For a straight-down entrance on Omarchy's Lua Hyprland configuration, add this scoped rule to `~/.config/hypr/looknfeel.lua`: diff --git a/install.sh b/install.sh index 6de113b..c8543b6 100755 --- a/install.sh +++ b/install.sh @@ -8790,7 +8790,7 @@ SIA_RELEASE_FILES=( bin/sialifetime.py manifest.json preview.png Panel.qml Cockpit.qml LiveView.qml Model.js README.md ROADMAP.md LICENSE SECURITY.md CHANGELOG.md GBRAIN_PIN GBRAIN_OVERLAY.patch config.example.json install.sh - uninstall.sh bin/sia bin/sia-setup bin/sia-brainstem bin/sia-ledger + uninstall.sh bin/sia bin/sia-setup bin/sia-intent-review bin/sia-brainstem bin/sia-ledger bin/sia-mcp bin/sia-continuity-worker bin/siabench.py bin/siabackup.py bin/siacapsule.py bin/sialib.py bin/siagraph.py bin/siathought.py bin/siasenses.py bin/siarestoreadmit.py bin/siamind.py bin/siaqueue.py bin/siarelease.py bin/siaactivation.py diff --git a/tests/test_cockpit_boundary_horizon.py b/tests/test_cockpit_boundary_horizon.py index 742ddef..9e16141 100644 --- a/tests/test_cockpit_boundary_horizon.py +++ b/tests/test_cockpit_boundary_horizon.py @@ -586,7 +586,7 @@ def test_current_status_shape_matches_backend_bounds(self): malformed.append(candidate) malformed.append(self._snapshot(workspace=["x" * 4097])) malformed.append(self._snapshot(intents=[{ - "id": "0123456789", "text": "x" * 70 + "y", + "id": "0123456789", "text": "x" * 300 + "y", "due": "2026-09-04", "days_left": 0, }])) malformed.append(self._snapshot(intents=[{ diff --git a/tests/test_intent_review.py b/tests/test_intent_review.py new file mode 100644 index 0000000..054b457 --- /dev/null +++ b/tests/test_intent_review.py @@ -0,0 +1,101 @@ +"""Commitment review never turns a reminder click into a completion.""" +import contextlib +import io +import json +import os +import subprocess +import unittest +from unittest import mock + +try: + import sia_test_home # noqa: F401; isolate runtime paths before imports +except ModuleNotFoundError: + from tests import sia_test_home # noqa: F401 + +from tests.test_cli import _load_script, sia +import siatakes + +REPO = os.path.dirname(os.path.dirname(__file__)) +review = _load_script('sia_intent_review_test', os.path.join(REPO, 'bin/sia-intent-review')) +IID = '5e1a06cc61' +ROW = dict(id=IID, text='Review ' + 'the supporting record ' * 8, + due='2026-09-08', holder='sia', created='2026-08-29T16:58:03Z') + + +def result(code=0, stdout=''): + return subprocess.CompletedProcess([], code, stdout, '') + + +class IntentReview(unittest.TestCase): + def test_list_exposes_identity_usable_by_show(self): + output = io.StringIO() + with mock.patch.object(siatakes, 'open_intents', return_value=[dict(ROW, days_left=-12)]), contextlib.redirect_stdout(output): + self.assertEqual(sia.cmd_intend(['--list']), 0) + self.assertIn(IID, output.getvalue()) + + def test_show_full_task_without_internal_paths(self): + output = io.StringIO() + with mock.patch.object(siatakes, 'open_intents', return_value=[dict(ROW, path='/private', days_left=-12)]), contextlib.redirect_stdout(output): + self.assertEqual(sia.cmd_intend(['--show', IID]), 0) + self.assertEqual(json.loads(output.getvalue()), ROW) + + def test_show_rejects_missing_and_ambiguous(self): + for rows in ([], [ROW, ROW]): + with mock.patch.object(siatakes, 'open_intents', return_value=rows), contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(sia.cmd_intend(['--show', IID]), 1) + + def test_show_rejects_option_and_shell_shaped_identity(self): + for iid in ('--help', '$(touch /tmp/no)', IID[:6]): + with mock.patch.object(siatakes, 'open_intents') as read, contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(sia.cmd_intend(['--show', iid]), 2) + read.assert_not_called() + + def run_review(self, answers, responses=None): + output = io.StringIO() + with mock.patch.object(review, 'run_cli', side_effect=responses or [result(stdout=json.dumps(ROW))]) as run, mock.patch('builtins.input', side_effect=answers), contextlib.redirect_stdout(output): + code = review.review(IID) + return code, run.call_args_list, output.getvalue() + + def test_cancel_empty_outcome_and_final_cancel_never_write(self): + for answers in ([''], ['done', ''], ['done', 'x' * 201], ['done', 'Reviewed evidence', 'no']): + _, calls, _ = self.run_review(answers) + self.assertEqual(calls, [mock.call(['--show', IID])]) + + def test_close_requires_outcome_and_explicit_confirmation(self): + code, calls, output = self.run_review(['done', 'Checked retained records', 'close'], [result(stdout=json.dumps(ROW)), result(stdout='done')]) + self.assertEqual(code, 0) + self.assertEqual(calls[-1], mock.call(['--done', IID, 'Checked retained records'])) + self.assertIn('next successful publication', output) + + def test_stale_close_never_reports_success(self): + code, _, output = self.run_review(['done', 'Reviewed', 'close'], [result(stdout=json.dumps(ROW)), result(1, 'no unique open intent')]) + self.assertEqual(code, 1) + self.assertNotIn('Closed.', output) + + def test_failed_read_never_prompts_or_writes(self): + code, calls, _ = self.run_review([], [result(1, 'not ready')]) + self.assertEqual(code, 1) + self.assertEqual(calls, [mock.call(['--show', IID])]) + + def test_response_identity_mismatch_refuses(self): + with self.assertRaises(ValueError): + self.run_review([], [result(stdout=json.dumps(dict(ROW, id='aaaaaaaaaa')))]) + + def test_terminal_control_codes_removed(self): + self.assertNotIn('\x1b', review.display('\x1b[31mred\ntext')) + + def test_noninteractive_review_never_calls_cli(self): + with mock.patch.object(review.sys.stdin, 'isatty', return_value=False), mock.patch.object(review, 'run_cli') as run, contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(review.main([IID]), 2) + run.assert_not_called() + + def test_full_text_status_contract_matches_qml(self): + self.assertTrue(sia.sialib._status_intents_shape([dict(id=IID, text='x' * 300, due='2026-09-08', days_left=-12)])) + self.assertFalse(sia.sialib._status_intents_shape([dict(id=IID, text='x' * 301, due='2026-09-08', days_left=-12)])) + script = "const fs=require('fs'),vm=require('vm');let s=fs.readFileSync('Model.js','utf8').replace(/^\\.pragma.*$/mg,'');let c={};vm.createContext(c);vm.runInContext(s,c);for(let n of [300,301])console.log(c.residentIntentShape([{id:'5e1a06cc61',text:'x'.repeat(n),due:'2026-09-08',days_left:-12}]));" + run = subprocess.run(['node', '-e', script], cwd=REPO, capture_output=True, text=True, check=True) + self.assertEqual(run.stdout.splitlines(), ['true', 'false']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_pulse_sync.py b/tests/test_pulse_sync.py index f4a2452..25db3eb 100644 --- a/tests/test_pulse_sync.py +++ b/tests/test_pulse_sync.py @@ -342,7 +342,7 @@ def test_current_status_nested_contract_is_exact_and_bounded(self): mutations.append(bad) bad = copy.deepcopy(valid) bad["intents"] = [{ - "id": "0123456789", "text": "x" * 70 + "y", + "id": "0123456789", "text": "x" * 300 + "y", "due": "2026-08-30", "days_left": 0, }] mutations.append(bad) From 33b2aaec5585aa29d6b4033e10f2721762156d8e Mon Sep 17 00:00:00 2001 From: sicarii Date: Sun, 20 Sep 2026 13:46:02 -0400 Subject: [PATCH 14/17] release: prepare SIA 1.8.1 and archive aggregate GitHub metrics --- CHANGELOG.md | 2 +- MAINTAINERS.md | 15 ++++++ Model.js | 2 +- README.md | 7 ++- bin/sia-mcp | 2 +- bin/sialib.py | 2 +- docs/CONTINUITY.md | 2 +- docs/MANUAL.md | 2 +- docs/WHITEPAPER.md | 2 +- manifest.json | 2 +- scripts/collect_github_metrics.py | 88 +++++++++++++++++++++++++++++++ tests/test_github_metrics.py | 46 ++++++++++++++++ 12 files changed, 163 insertions(+), 9 deletions(-) create mode 100644 scripts/collect_github_metrics.py create mode 100644 tests/test_github_metrics.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d11569d..58a23a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 1.8.1 — 2026-09-20 · reviewable commitments and a steadier cockpit ### Cockpit diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 101569f..5dbbaed 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -3,6 +3,21 @@ SIA is maintained by **Khephri Labs** ([@AnubisQuantumCipher](https://github.com/AnubisQuantumCipher)), who holds release authority and the marketplace verify chain. +## Download and traffic reporting + +Run `python3 scripts/collect_github_metrics.py` with a `gh` login that can read +this repository's traffic. It archives GitHub's aggregate clone/view windows +and uploaded release-asset download counters under +`~/.local/state/sia-maintainer/github-traffic/`. Schedule it daily to retain +history beyond GitHub's rolling traffic window. It does not modify the SIA +installer or collect reports from users' machines. + +Keep daily observations as observations: overlapping traffic windows and +unique-cloner counts cannot be summed into lifetime people or installations. +Uploaded release assets expose download counters; generated source archives +are not uploaded assets. Neither clones nor downloads prove a successful +installation. Failed API requests produce no partial snapshot. + ## Credited reviewers Two outside contributors found SIA within a day of its marketplace listing and diff --git a/Model.js b/Model.js index 402d8b3..39f4008 100644 --- a/Model.js +++ b/Model.js @@ -13,7 +13,7 @@ // one edge of the canvas. .pragma library -function releaseVersion() { return "1.8.0" } +function releaseVersion() { return "1.8.1" } // The checkout and the resident runtime advance as one release generation. // Only an exact release match may expose the cockpit. Comparison stays on diff --git a/README.md b/README.md index 79a5ec5..8a4b5c5 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,12 @@ watch its generated-entry stream and query the memory it has admitted. You can audit every stored word because the corpus is markdown in git and the daemon signs its own acts. -**Current release: v1.8.0.** Two receipts bind storage by device and inode +**Current release: v1.8.1.** This maintenance update makes commitments +reviewable from the cockpit, preserves the graph during status refreshes, +and reduces entrance and idle rendering work. See [the changelog](CHANGELOG.md) +for the complete changes. + +**Storage recovery introduced in v1.8.0.** Two receipts bind storage by device and inode number — the installer's corpus receipt and the delivery epoch's adoption — and a btrfs subvolume change, an `rsync` of the home directory, or a restore by copy changes those numbers. After that every pulse refused, `sia ready` diff --git a/bin/sia-mcp b/bin/sia-mcp index 34189a6..571d10f 100755 --- a/bin/sia-mcp +++ b/bin/sia-mcp @@ -22,7 +22,7 @@ import siaqueue HOME = os.path.expanduser("~") SIA = os.path.join(HOME, ".local/bin/sia") STATE = os.path.join(HOME, ".local/state/sia") -SERVER_VERSION = "1.8.0" +SERVER_VERSION = "1.8.1" MODERN_PROTOCOL = "2026-07-28" LEGACY_PROTOCOLS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05") diff --git a/bin/sialib.py b/bin/sialib.py index 4d1758b..d158d08 100644 --- a/bin/sialib.py +++ b/bin/sialib.py @@ -636,7 +636,7 @@ def _build_organs(): HIGH_TAGS = ["integrity-failure", "refusal", "crash", "coredump", "failed", "collapse", "healing", "urgent"] -VERSION = "1.8.0" +VERSION = "1.8.1" # Corpus bytes and their derived PGLite/graph projections form one publication diff --git a/docs/CONTINUITY.md b/docs/CONTINUITY.md index 96ae80f..5dce2d5 100644 --- a/docs/CONTINUITY.md +++ b/docs/CONTINUITY.md @@ -1,6 +1,6 @@ # SIA continuity -**Describes SIA v1.8.0 · 2026-09-19** +**Describes SIA v1.8.1 · 2026-09-20** SIA continuity is the backup and clean-machine recovery boundary for SIA, the Omarchy Brain. “Brain” is a product metaphor for auditable local machine memory; diff --git a/docs/MANUAL.md b/docs/MANUAL.md index df9073b..0929578 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -3,7 +3,7 @@ “Brain” is a product metaphor for auditable local machine memory; it is not a biological brain and does not establish cognition or neuroscience. -**Describes SIA v1.8.0 · 2026-09-19** +**Describes SIA v1.8.1 · 2026-09-20** *Sia: the Egyptian personification of perception, who rode the solar barque beside Hu (utterance) and Heka (magic).* diff --git a/docs/WHITEPAPER.md b/docs/WHITEPAPER.md index dcf7678..7090d5c 100644 --- a/docs/WHITEPAPER.md +++ b/docs/WHITEPAPER.md @@ -3,7 +3,7 @@ “Brain” is a product metaphor for auditable local machine memory; it is not a biological brain and does not establish cognition or neuroscience. -**Khephri Labs · open source (MIT) · 2026-09-19 · v1.8.0** +**Khephri Labs · open source (MIT) · 2026-09-20 · v1.8.1** *Measurements and deployment details herein are from the reference deployment: an Omarchy Linux 4.0 (aarch64) machine running the full optional-integration set.* diff --git a/manifest.json b/manifest.json index f8d1e73..6088bff 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "khephri.sia", "name": "SIA", - "version": "1.8.0", + "version": "1.8.1", "author": "Khephri Labs", "license": "MIT", "description": "SIA — the Omarchy Brain. “Brain” is a product metaphor for auditable local machine memory; it is not a biological brain and does not establish cognition or neuroscience. Browse the local graph, generated records, and evidence-chain status.", diff --git a/scripts/collect_github_metrics.py b/scripts/collect_github_metrics.py new file mode 100644 index 0000000..128fe60 --- /dev/null +++ b/scripts/collect_github_metrics.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Archive GitHub's aggregate repository metrics, never installer telemetry.""" +import argparse +import datetime +import json +import os +from pathlib import Path +import subprocess +import tempfile + + +def fetch(repository, endpoint): + result = subprocess.run( + ["gh", "api", f"repos/{repository}/{endpoint}"], + capture_output=True, text=True, timeout=120, check=False) + if result.returncode: + raise RuntimeError(f"GitHub {endpoint} request failed; check gh authentication and repository access") + return json.loads(result.stdout) + + +def collect(repository): + traffic = {} + for endpoint in ("clones", "views"): + value = fetch(repository, "traffic/" + endpoint) + if (not isinstance(value, dict) + or type(value.get("count")) is not int + or type(value.get("uniques")) is not int + or not isinstance(value.get(endpoint), list)): + raise ValueError(f"Invalid {endpoint} response") + traffic[endpoint] = value + releases = fetch(repository, "releases?per_page=100") + if not isinstance(releases, list): + raise ValueError("Invalid releases response") + assets = [{"tag": release["tag_name"], "assets": [ + {"id": asset["id"], "name": asset["name"], + "download_count": asset["download_count"]} + for asset in release["assets"]]} for release in releases] + return { + "schema": "sia-github-metrics-v1", + "repository": repository, + "observed_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "traffic": traffic, + "recent_releases": assets, + "limitations": [ + "Traffic reports GitHub's rolling 14-day window, not lifetime totals.", + "Overlapping windows and unique counts must not be summed.", + "Clones and downloads do not establish successful installations or distinct people.", + "Release data covers the latest API page (up to 100 releases); source archives are not uploaded assets.", + "This collector contacts GitHub only; installed SIA clients send it no reports.", + ], + } + + +def save(snapshot, directory): + directory = Path(directory).expanduser() + directory.mkdir(parents=True, exist_ok=True, mode=0o700) + # A unique name preserves repeated samples without replacing older history. + stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H%M%SZ") + fd, name = tempfile.mkstemp(prefix=stamp + "-", suffix=".tmp", dir=directory) + try: + with os.fdopen(fd, "w") as stream: + json.dump(snapshot, stream, indent=2) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + target = Path(name).with_suffix(".json") + os.rename(name, target) + return target + finally: + if os.path.exists(name): + os.unlink(name) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repository", default="AnubisQuantumCipher/sia") + parser.add_argument("--output", default="~/.local/state/sia-maintainer/github-traffic") + args = parser.parse_args() + try: + snapshot = collect(args.repository) + print(save(snapshot, args.output)) + except (OSError, ValueError, KeyError, TypeError, RuntimeError, + subprocess.SubprocessError) as error: + parser.exit(1, f"Metrics collection failed: {error}\n") + + +if __name__ == "__main__": + main() diff --git a/tests/test_github_metrics.py b/tests/test_github_metrics.py new file mode 100644 index 0000000..14164e1 --- /dev/null +++ b/tests/test_github_metrics.py @@ -0,0 +1,46 @@ +"""Metrics retain raw windows and publish no partial collection.""" +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +from unittest import mock + +PATH = Path(__file__).resolve().parents[1] / "scripts/collect_github_metrics.py" +spec = importlib.util.spec_from_file_location("github_metrics_test", PATH) +metrics = importlib.util.module_from_spec(spec) +spec.loader.exec_module(metrics) + + +class GithubMetrics(unittest.TestCase): + def test_preserves_raw_windows_without_claiming_installs(self): + clones = dict(count=439, uniques=212, clones=[]) + views = dict(count=494, uniques=205, views=[]) + releases = [{"tag_name": "v1.8.1", "assets": [ + {"id": 7, "name": "sia.tar.gz", "download_count": 9}]}] + with mock.patch.object(metrics, "fetch", side_effect=[clones, views, releases]): + report = metrics.collect("owner/repo") + self.assertEqual(report["traffic"], {"clones": clones, "views": views}) + self.assertNotIn("installations", report) + self.assertTrue(any("must not be summed" in text for text in report["limitations"])) + with tempfile.TemporaryDirectory() as directory: + first = metrics.save(report, directory) + second = metrics.save(report, directory) + self.assertNotEqual(first, second) + self.assertEqual(json.loads(first.read_text()), report) + self.assertEqual(first.stat().st_mode & 0o777, 0o600) + self.assertEqual(list(Path(directory).glob("*.tmp")), []) + + def test_failed_fetch_does_not_write_a_partial_snapshot(self): + with mock.patch.object(metrics, "fetch", side_effect=RuntimeError("unavailable")), \ + mock.patch.object(metrics, "save") as save, \ + mock.patch("sys.argv", ["collect_github_metrics.py"]): + with self.assertRaises(SystemExit) as stopped: + metrics.main() + self.assertEqual(stopped.exception.code, 1) + save.assert_not_called() + + def test_malformed_traffic_is_not_recorded_as_zero(self): + with mock.patch.object(metrics, "fetch", return_value={"message": "denied"}): + with self.assertRaises(ValueError): + metrics.collect("owner/repo") From e54309b9f68dd5a6d6798397dfff2dd5d6067713 Mon Sep 17 00:00:00 2001 From: sicarii Date: Sun, 20 Sep 2026 13:56:33 -0400 Subject: [PATCH 15/17] docs: prepare 1.8.1 to replace older marketplace review --- ROADMAP.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 92beaa7..098bdc0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -111,6 +111,12 @@ freeze ends on evidence, not on mood. first with a declaration-only `state=none` commit. That administrative closure is not the fix: the following push is the one permitted fix-only movement of `main`, and a later validation attempt must bind its resulting exact SHA separately. + - Operator-directed replacement: the operator explicitly requested that 1.8.1, + rather than the older commit, be reviewed. Retire issue #7719 before moving + `main`, merge the release through required checks, then submit its final + default-branch SHA for fresh review. This closes the old attempt; it does not + transfer its validation or approval to the replacement. Freeze `main` again + before submitting the replacement request. - The rule is mechanically checked by the `marketplace-freeze` job in `.github/workflows/ci.yml`, which reads the declaration below and turns a push to the frozen branch that is not the bound commit red. It cannot protect the @@ -308,10 +314,12 @@ maintainer's constraint is not "bind a good commit" but "bind the commit that is currently HEAD" — and this repository kept moving HEAD. The correction is upstream of the verify form: **do not push to `main` while a -verification is pending.** The current review binds `8a624ef…` with the form intact. -Until the listing flips or the maintainer closes the cycle, releases queue on branches. -After it flips, normal cadence resumes — one re-bind per release, after tagging, and -never while validation is in flight. +verification is pending.** The operator has directed replacement of the old +`f9a2b9838926904fff236d0c74c3ebd8fa30e487` request (#7719) with SIA 1.8.1. +Retire that request before merging release PR #20. Once required checks pass, +merge the release, establish the final frozen default-branch HEAD, and request +fresh verification of that exact commit. Do not reuse the old scan as evidence +for 1.8.1. Keep subsequent changes on branches until the replacement review ends. The binding is declared here, in one machine-readable line, because a rule only a reader can see is the rule that was already broken three times. CI reads this exact @@ -320,7 +328,7 @@ line (`.github/workflows/ci.yml`, job `marketplace-freeze`): while the state is Closing the cycle means editing the line to `state=none`; re-binding means editing `sha=` and nothing else in the same commit, because no commit can name its own SHA. - sia-freeze: state=pending branch=main sha=f9a2b9838926904fff236d0c74c3ebd8fa30e487 + sia-freeze: state=none branch=main sha=f9a2b9838926904fff236d0c74c3ebd8fa30e487 --- From d8cb7a82dfb47eb7b90c7e4c2fc30dd17b5fcf12 Mon Sep 17 00:00:00 2001 From: sicarii Date: Sun, 20 Sep 2026 14:31:36 -0400 Subject: [PATCH 16/17] ci: enable CodeRabbit reviews for draft pull requests --- .coderabbit.yaml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..cf8c1bc --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +reviews: + auto_review: + enabled: true + drafts: true + auto_incremental_review: true From 8867333a0ee2a3a2d62d7874050344e87ebf97fb Mon Sep 17 00:00:00 2001 From: sicarii Date: Mon, 21 Sep 2026 23:39:50 -0400 Subject: [PATCH 17/17] review: a recall that never ran is not a completed evidence read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An adversarial multi-lens review of this release raised 18 findings; 8 survived independent refutation. CodeRabbit has reported "pass" with zero duration and "Review rate limited" on every head of this PR, so nothing had actually reviewed it. **The one that matters.** `_recall` returned `RecallEvidence(True, "", ...)` for an option-shaped claim, reporting a corpus search that was never attempted as a completed evidence read. `GradingEvidenceUnavailable` in this same file states the rule it broke: "only a completed evidence read may be judged UNRESOLVABLE". The consequence was not cosmetic. `_grading_evidence` proceeds whenever the recall reports completed, so `_organ_evidence` still yielded citations, the judge could return TRUE or FALSE from that half of the lane, and the take was resolved, Brier-scored and fed into calibration. The rendered page said it had been "judged against a signed evidence snapshot" with nothing distinguishing "recall ran and admitted nothing" from "recall never ran". The guard was also far too broad, and I measured the engine rather than reasoning about it. Against the shipped gbrain: `--help` and `-h` print usage text, but `-1 regressions will land by March` runs an ordinary search and returns results. So the guard was removing the semantic recall lane from exactly the honest takes `sia take -- …` exists to let a caller register. The reviewer's proposed remedy, passing a `--` terminator to the engine, would have made it worse: I tried it, and the engine consumes the marker and fails with `invalid_params`, losing the query text entirely. `_option_shaped` now discriminates on token shape, which is the only thing that survives contact with that parser. The test shipped in this branch asserted `completed is True` and an empty reason, so it was locking the defect in rather than guarding against it. It now asserts the inverse, and a second test proves a negative-number claim still reaches the engine. This is the second time in one day a test I read was defending the thing it should have caught. **Three false claims in the release notes**, which for this project is the cardinal sin, all verified against `origin/main` before rewriting: - "Opening no longer stacks a full-screen Qt fade" — 1.8.0 contains no Qt animation at all. Removed; the true remainder of the paragraph is kept. - "Native Qt rings replace the full-size transparent glow canvas" — exactly inverted. `glowCanvas` does not exist on main; this branch ADDS it, moving the glow off the main graph canvas so animating it no longer forces a full repaint. Reworded to what the diff does. - The notes were cockpit-only and silently omitted user-facing fixes, including `mind` becoming an accepted top-level config section that the shipped `config.example.json` already carried. Added, along with the grading fix above. **Three smaller confirmed defects.** `intentReviewFeedback` was set and then the panel closed, so the message was never seen and never cleared — it would surface stale on the next open. Now cleared in both reset paths, matching how `workspaceLockFeedback` is already handled. A hardcoded `font.family: "sans-serif"` bypassed the theme token and was the only literal family in the file. And a new Node-dependent test hard-failed where every other JS-contract test in the repo skips: a missing toolchain is not a broken contract. pytest, the four suites around the changed areas 91 passed, 259 subtests engine behaviour measured, not assumed --- CHANGELOG.md | 25 ++++++++++++--- Cockpit.qml | 4 ++- bin/siatakes.py | 53 ++++++++++++++++++++++++++------ tests/test_intent_review.py | 9 +++++- tests/test_siatakes_hardening.py | 42 ++++++++++++++++++++----- 5 files changed, 109 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58a23a6..c748f46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## 1.8.1 — 2026-09-20 · reviewable commitments and a steadier cockpit +### Grading and configuration + +A claim the engine's CLI would read as a flag is no longer reported as a +completed corpus search. It never ran, so the recall now says so and the take +stays open, which is what `GradingEvidenceUnavailable` has always specified: +"only a completed evidence read may be judged UNRESOLVABLE". Previously such a +take was closed and scored from the organ half of the evidence lane alone, while +its published page still said it had been judged against a signed evidence +snapshot. The guard is also narrower: it rejected every claim opening with a +dash, which silently removed the recall lane from legitimate takes like +"-1 regressions will land by March" that `sia take -- …` exists to let you +register. Measured against the shipped engine, those run as ordinary searches. + +`mind` is accepted as a top-level configuration section, which the shipped +`config.example.json` already carried. + ### Cockpit Commitments now show their full task and calendar due date, explain that overdue @@ -12,11 +28,12 @@ or failed requests never claim completion. `sia intend --show ` exposes the full open task as JSON without internal storage paths. Review does not hold a corpus lock while waiting for the operator and never executes task prose. -Opening no longer stacks a full-screen Qt fade on the desktop animation. -Snapshot refresh and graph motion wait until arrival, and the graph image is +Snapshot refresh and graph motion now wait until arrival, and the graph image is painted before presentation to avoid exposing uninitialized pixels on remap. -Native Qt rings replace the full-size transparent glow canvas; inspecting a -settled node no longer keeps the layout simulation running. Routine status +The freshness glow and the root halo move off the main graph canvas onto a +dedicated lighter one driven by native Qt rings, so animating them no longer +forces a full graph repaint. Inspecting a settled node no longer keeps the +layout simulation running. Routine status refreshes use the existing cockpit instead of flashing the first-install gate; snapshot admission and action guards remain unchanged. Status counts guard both the status and graph snapshots. Backup schedule details expand on demand, and diff --git a/Cockpit.qml b/Cockpit.qml index 43c6a85..7428c52 100644 --- a/Cockpit.qml +++ b/Cockpit.qml @@ -2274,6 +2274,7 @@ Item { if (root.workspaceLockMismatch) root.clearWorkspaceLock() opened = true workspaceLockFeedback = "" + intentReviewFeedback = "" root.clearVerification() continuityActionMsg = "" continuityActionOk = false @@ -2335,6 +2336,7 @@ Item { root.clearContinuityInputs() root.clearWorkspaceLock() workspaceLockFeedback = "" + intentReviewFeedback = "" } function dismiss() { @@ -6397,7 +6399,7 @@ Item { ? Qt.alpha(root.fg, 0.6) : thoughtRow.urgencyState === "urgent" ? root.urgent : Qt.alpha(root.fg, 0.85) - font.family: "sans-serif" + font.family: root.fontFamily font.pixelSize: Style.font.bodySmall } Text { diff --git a/bin/siatakes.py b/bin/siatakes.py index 4c5d1a0..41d8a73 100644 --- a/bin/siatakes.py +++ b/bin/siatakes.py @@ -1152,17 +1152,50 @@ def _unverified_jackal_slug(slug): ("events/jackal/", "epochs/jackal/")) +def _option_shaped(query): + """Whether the engine's CLI would read this claim as a flag, not as text. + + Measured against the shipped engine rather than reasoned about. `--help` + and `-h` make `gbrain query` print its usage text instead of results. + `-1 regressions will land by March` runs an ordinary search and returns + them. The engine has no end-of-options escape: passing `--` consumes the + text and fails with `invalid_params`, so it would break the recall rather + than rescue it. Token shape is the only safe discrimination. + + A leading dash followed by a letter is a flag. A leading dash followed by + a digit or a decimal point is a negative number, which is a legitimate + claim that `sia take -- …` deliberately exists to let a caller register. + """ + q = query.lstrip() + if not q.startswith("-"): + return False + rest = q.lstrip("-") + return not (rest[:1].isdigit() or rest.startswith(".")) + + def _recall(query, k=6): - if not isinstance(query, str) or not query.strip() \ - or query.lstrip().startswith("-"): - # An option-shaped or empty claim cannot be recalled: the engine's - # CLI reads it as a flag and prints its usage text instead of a - # result list, which admission then refused on every nightly run, - # leaving the take due forever. Such takes exist from before - # `sia take --help` was refused. A completed recall with no - # admitted evidence is the documented path: the judge sees - # "(none)" and grades UNRESOLVABLE, which closes the take. - return RecallEvidence(True, "", frozenset()) + if not isinstance(query, str) or not query.strip(): + return RecallEvidence( + False, "", frozenset(), + "claim is empty, so no recall was attempted") + if _option_shaped(query): + # The engine's CLI reads this claim as a flag and prints usage text + # instead of a result list, which admission then refuses. That is an + # evidence lane that DID NOT RUN, and `GradingEvidenceUnavailable` + # states the consequence: the take stays open, because "only a + # completed evidence read may be judged UNRESOLVABLE". + # + # Reporting `completed=True` here closed the take on a corpus search + # that never happened. The judge still received organ snapshots, could + # return TRUE or FALSE from that half of the lane, and the published + # page said it was "judged against a signed evidence snapshot" with + # nothing to distinguish "recall ran and admitted nothing" from "recall + # never ran". A take left open is a visible junk claim; a take closed + # that way is a scored one. + return RecallEvidence( + False, "", frozenset(), + "claim is option-shaped, so the engine CLI would read it as a " + "flag and no recall was attempted") try: # Imported lazily to avoid the sialib -> siatakes module cycle. Every # shipped PGLite operation must enter the same cross-process owner diff --git a/tests/test_intent_review.py b/tests/test_intent_review.py index 054b457..90429b1 100644 --- a/tests/test_intent_review.py +++ b/tests/test_intent_review.py @@ -3,6 +3,7 @@ import io import json import os +import shutil import subprocess import unittest from unittest import mock @@ -93,7 +94,13 @@ def test_full_text_status_contract_matches_qml(self): self.assertTrue(sia.sialib._status_intents_shape([dict(id=IID, text='x' * 300, due='2026-09-08', days_left=-12)])) self.assertFalse(sia.sialib._status_intents_shape([dict(id=IID, text='x' * 301, due='2026-09-08', days_left=-12)])) script = "const fs=require('fs'),vm=require('vm');let s=fs.readFileSync('Model.js','utf8').replace(/^\\.pragma.*$/mg,'');let c={};vm.createContext(c);vm.runInContext(s,c);for(let n of [300,301])console.log(c.residentIntentShape([{id:'5e1a06cc61',text:'x'.repeat(n),due:'2026-09-08',days_left:-12}]));" - run = subprocess.run(['node', '-e', script], cwd=REPO, capture_output=True, text=True, check=True) + node = shutil.which("node") + if node is None: + # Every other JS-contract test in this repo skips when Node is + # absent rather than failing: a toolchain that is missing is not a + # contract that broke. + self.skipTest("Node is unavailable for executable Model.js logic") + run = subprocess.run([node, '-e', script], cwd=REPO, capture_output=True, text=True, check=True) self.assertEqual(run.stdout.splitlines(), ['true', 'false']) diff --git a/tests/test_siatakes_hardening.py b/tests/test_siatakes_hardening.py index 21f621e..56d29db 100644 --- a/tests/test_siatakes_hardening.py +++ b/tests/test_siatakes_hardening.py @@ -67,20 +67,46 @@ def test_judge_config_roster_admits_every_shipped_top_level_key(self): self.assertEqual(sialib._CONFIG_TOP_LEVEL_KEYS - siatakes.CONFIG_TOP_LEVEL_KEYS, set()) - def test_option_shaped_claim_is_never_sent_to_the_engine(self): - """A take whose claim is "--help" (registered before `sia take --help` - was refused) made the engine print its usage text instead of a - result list; admission refused it and the take stayed due forever. - The claim is not recalled at all: a completed recall with no - admitted evidence lets the judge grade it UNRESOLVABLE.""" + def test_unrecallable_claim_reports_a_read_that_did_not_complete(self): + """A claim the engine CLI would read as a flag is never sent to it, and + the recall is reported as NOT completed. + + This assertion is the inverse of the one it replaces, which required + `completed is True` for a corpus search that never ran. That closed the + take as UNRESOLVABLE on half an evidence lane, while the published page + still said it had been judged against a signed evidence snapshot. + `GradingEvidenceUnavailable` states the rule: "only a completed evidence + read may be judged UNRESOLVABLE".""" with mock.patch.object(sialib, "gbrain", side_effect=AssertionError("engine queried")): for claim in ("--help", " -h", "", " "): recall = siatakes._recall(claim) - self.assertTrue(recall.completed, claim) + self.assertFalse(recall.completed, claim) self.assertEqual(recall.text, "") self.assertEqual(recall.citations, frozenset()) - self.assertEqual(recall.reason, "") + self.assertTrue(recall.reason, claim) + + def test_a_negative_number_claim_still_reaches_the_engine(self): + """`-1 regressions will land by March` is a legitimate take, which + `sia take -- …` exists to let a caller register, and the engine runs it + as an ordinary search. The guard used to reject every claim opening + with a dash, silently removing the semantic recall lane from takes the + CLI deliberately supports.""" + self.assertFalse(siatakes._option_shaped("-1 regressions will land")) + self.assertFalse(siatakes._option_shaped("-.5 drop in latency")) + self.assertTrue(siatakes._option_shaped("--help")) + self.assertTrue(siatakes._option_shaped(" -h")) + + seen = [] + + def _fake_gbrain(args, **kw): + seen.append(args) + raise RuntimeError("stop after dispatch") + + with mock.patch.object(sialib, "gbrain", _fake_gbrain): + siatakes._recall("-1 regressions will land by March") + self.assertTrue(seen, "the claim must reach the engine") + self.assertIn("-1 regressions will land by March", seen[0]) def test_model_and_jackal_traversal_refuse_before_judging(self): aliases = (