RQ1+RQ2 start-line: live per-cell condition assembler + guarded launcher + Holm ratification
Wire the live per-cell condition assembler (cmd_chat/sor/assembler.py): compose
the existing R1/R4/R5/R6 pieces into a condition-encoding CircuitSpec per frozen
§2 cell, so a cell_id maps to a genuinely distinct, ForwarderPlan-gated
(engine != local) isolated circuit rather than the plain 3-hop control:
- RQ1 bridge-on / on+padding insert a live bridge hop (+ R1 PADDING stream);
- RQ2 bridge-/directory-federated genuinely span >= 2 houses
(federation.select_federated_path, split-knowledge).
Plans only: opens no socket, moves no traffic, stands up no engine.
battery.assembler_dry_check validates on FIXTURES (6/6 cells distinct
fingerprints, all isolation-gated, same (cell,seed) reproduces, RQ1 bridge +
padding arms live, RQ2 federation >= 2 houses); write-once, kept out of any
confirmatory data dir. confirmatory_run preflight now runs it; the --operator-go
path no longer refuses cells as "not wired" — triple-lock + green preflight HOLD
on grid completion, then surface the operator's immutable data-run gate. No
confirmatory cell is ever fabricated.
Start-line instrument-validation gate (cmd_chat/sor/gate.py), grid + containment
pin (grid.py), and the guarded launcher (confirmatory_run.py) land alongside.
Holm: hold family_size = 7, report the 4 RQ1/RQ2 tests -> multipliers 7,6,5,4.
This is the pre-registration, not a deviation (prereg §6 [APPROVAL] size-7 family
+ D6 disclosure in the lead paper); docs/stage-05-holm-clarification.md marked
RATIFIED. RQ3-fold and alpha-split declined.
Containment intact; prereg untouched (SHA f22331a72e...); worktree-only. output/
gitignored (runtime artifacts, never source). Full SOR suite 156 passed. No
confirmatory data collected — the live data run remains the human gate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
"""Live per-cell condition assembler — turns a frozen §2 cell into a genuinely
|
||||
distinct, ForwarderPlan-gated isolated circuit.
|
||||
|
||||
Each confirmatory cell is *not* the plain 3-hop control: this composes the
|
||||
already-built R4/R5/R6 pieces so that a ``cell_id`` maps to a real circuit whose
|
||||
**structure encodes its condition**, every hop gated by the R4 isolation guard
|
||||
(:class:`ForwarderPlan`, ``assert engine != local``):
|
||||
|
||||
* **RQ1 bridge=off** — a single-house 3-hop path (R1 ``select_path``).
|
||||
* **RQ1 bridge=on** — the middle hop is a **bridge** node (R6 ``BlindBridge``
|
||||
role), so the circuit is routed through a bridge; structurally distinct.
|
||||
* **RQ1 bridge=on+padding** — bridge on, plus the R1 PADDING stream drives
|
||||
cover traffic on the path (``padding_applied``).
|
||||
* **RQ2 1house-N** — single house sized to the **matched-N** node count.
|
||||
* **RQ2 bridge-federated** — entry/exit in *different* houses joined by a
|
||||
``BlindBridge`` member: the path spans ≥ 2 houses.
|
||||
* **RQ2 directory-federated** — a signed-roster :class:`Directory` and
|
||||
``select_federated_path`` pick a path spanning ≥ 2 houses (split knowledge).
|
||||
|
||||
Determinism comes from the R1 ``SorRng`` / frozen seed so the same ``(cell,
|
||||
seed)`` reproduces the same circuit; different cells produce different circuits.
|
||||
This module builds **plans only** — it opens no socket, moves no traffic, and
|
||||
stands up no engine. The live traffic that would consume these plans is the
|
||||
human-gated confirmatory data run (``confirmatory_run``); here we only validate,
|
||||
on fixtures, that each cell is a real, distinct, isolated-engine circuit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from cmd_chat.sor.config import SorRng, select_path
|
||||
from cmd_chat.sor.consent import _fingerprint_of, persona_sign
|
||||
from cmd_chat.sor.federation import Directory, build_peer_frame, parse_peer_frame
|
||||
from cmd_chat.sor.forwarder import ContainmentError, ForwarderPlan
|
||||
|
||||
HOUSES = ("houseA", "houseB")
|
||||
DEFAULT_HOUSE_SIZE = 4
|
||||
DEFAULT_N_HOUSES = 2
|
||||
DEFAULT_HOPS = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HopSpec:
|
||||
"""One hop of an assembled circuit. ``is_bridge`` marks the R6 bridge role."""
|
||||
|
||||
index: int
|
||||
role: str # "entry" | "relay" | "exit" | "bridge"
|
||||
house: str
|
||||
node_label: str # deterministic node identity (fingerprint or house#idx)
|
||||
is_bridge: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CircuitSpec:
|
||||
"""A fully-determined, condition-encoding circuit for one cell. Immutable; its
|
||||
:meth:`plans` are the R4 isolation-gated forwarder targets."""
|
||||
|
||||
cell_id: str
|
||||
rq: str
|
||||
seed: int
|
||||
engine: str
|
||||
topology: str
|
||||
bridge_present: bool
|
||||
padding_applied: bool
|
||||
matched_n: int
|
||||
houses: Tuple[str, ...]
|
||||
hops: Tuple[HopSpec, ...]
|
||||
|
||||
def _semantic(self) -> Dict:
|
||||
"""The condition-defining content (no container names — those derive from
|
||||
this), so the fingerprint reflects the *circuit*, not incidental naming."""
|
||||
return {
|
||||
"cell_id": self.cell_id, "rq": self.rq, "seed": self.seed,
|
||||
"topology": self.topology, "bridge_present": self.bridge_present,
|
||||
"padding_applied": self.padding_applied, "matched_n": self.matched_n,
|
||||
"houses": list(self.houses),
|
||||
"hops": [[h.index, h.role, h.house, h.node_label, h.is_bridge]
|
||||
for h in self.hops],
|
||||
}
|
||||
|
||||
def fingerprint(self) -> str:
|
||||
"""SHA-256 over the semantic content. Same (cell, seed) → same fingerprint
|
||||
(reproducible); different cells → different fingerprints (distinct)."""
|
||||
blob = json.dumps(self._semantic(), sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(blob.encode()).hexdigest()
|
||||
|
||||
def container_name(self, i: int) -> str:
|
||||
return f"sorcell-{self.fingerprint()[:8]}-h{i}"
|
||||
|
||||
def plans(self) -> List[ForwarderPlan]:
|
||||
"""The R4 isolation-gated forwarder target for each hop. Building a plan
|
||||
re-asserts ``engine != local`` — a non-isolated engine raises here."""
|
||||
return [ForwarderPlan(engine=self.engine, container=self.container_name(h.index))
|
||||
for h in self.hops]
|
||||
|
||||
def isolation_gated(self) -> bool:
|
||||
"""True iff every hop builds a valid isolated ForwarderPlan (containment)."""
|
||||
try:
|
||||
self.plans()
|
||||
return True
|
||||
except ContainmentError:
|
||||
return False
|
||||
|
||||
def span_houses(self) -> int:
|
||||
return len({h.house for h in self.hops})
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
d = self._semantic()
|
||||
d["fingerprint"] = self.fingerprint()
|
||||
d["engine"] = self.engine
|
||||
d["span_houses"] = self.span_houses()
|
||||
d["isolation_gated"] = self.isolation_gated()
|
||||
return d
|
||||
|
||||
|
||||
def _persona(seed: int, tag: str) -> Tuple[bytes, str]:
|
||||
"""A deterministic fixture Ed25519 keypair as (secret_raw, pub_b64)."""
|
||||
raw = hashlib.sha256(f"sor-persona|{seed}|{tag}".encode()).digest()[:32]
|
||||
sk = Ed25519PrivateKey.from_private_bytes(raw)
|
||||
pub = base64.standard_b64encode(sk.public_key().public_bytes_raw()).decode()
|
||||
return raw, pub
|
||||
|
||||
|
||||
def _bridge_label(seed: int) -> str:
|
||||
return f"bridge#{hashlib.sha256(f'sor-bridge|{seed}'.encode()).hexdigest()[:8]}"
|
||||
|
||||
|
||||
def _house_nodes(seed: int, pool: int, take: int, house: str, salt: int = 0) -> List[str]:
|
||||
idx = select_path(SorRng(seed ^ salt), pool, take)
|
||||
return [f"{house}#{i}" for i in idx]
|
||||
|
||||
|
||||
def _build_directory(seed: int, house_size: int, n_houses: int) -> Directory:
|
||||
"""A signature-verified directory over ``n_houses`` fixture houses so
|
||||
``select_federated_path`` can span ≥ 2 of them (roster forgery is refused)."""
|
||||
d = Directory()
|
||||
for house in HOUSES[:n_houses]:
|
||||
host_secret, host_pub = _persona(seed, f"host|{house}")
|
||||
members = [_persona(seed, f"{house}|m{m}")[1] for m in range(house_size)]
|
||||
frame = build_peer_frame(host_secret, host_pub, house, members)
|
||||
roster = parse_peer_frame(frame) # verifies the signature
|
||||
if roster is None or not d.add_roster(roster):
|
||||
raise ValueError(f"assembler: fixture roster for {house} failed to verify")
|
||||
return d
|
||||
|
||||
|
||||
def assemble(
|
||||
cell, seed: int, *, engine: str = "docker",
|
||||
hops: int = DEFAULT_HOPS, house_size: int = DEFAULT_HOUSE_SIZE,
|
||||
n_houses: int = DEFAULT_N_HOUSES,
|
||||
) -> CircuitSpec:
|
||||
"""Assemble the genuinely distinct, isolation-gated circuit for ``cell`` at
|
||||
``seed``. Dispatches on the frozen §2 factors (bridge / topology). Raises
|
||||
ValueError if a federated cell cannot honour its ≥2-house span."""
|
||||
bridge = cell.factors.get("bridge", "off")
|
||||
topo = cell.factors.get("topology", "1house")
|
||||
matched_n = house_size * n_houses # federated arm's total consenting nodes
|
||||
|
||||
hop_specs: List[HopSpec]
|
||||
houses: Tuple[str, ...]
|
||||
bridge_present = False
|
||||
padding_applied = False
|
||||
|
||||
if cell.rq == "RQ1":
|
||||
houses = (HOUSES[0],)
|
||||
if bridge == "off":
|
||||
labels = _house_nodes(seed, house_size, hops, HOUSES[0])
|
||||
roles = ["entry", "relay", "exit"][:hops]
|
||||
hop_specs = [HopSpec(i, roles[i], HOUSES[0], labels[i], False)
|
||||
for i in range(hops)]
|
||||
else: # "on" or "on+padding": route the middle hop through a bridge node.
|
||||
ends = _house_nodes(seed, house_size, 2, HOUSES[0])
|
||||
hop_specs = [
|
||||
HopSpec(0, "entry", HOUSES[0], ends[0], False),
|
||||
HopSpec(1, "bridge", "bridge", _bridge_label(seed), True),
|
||||
HopSpec(2, "exit", HOUSES[0], ends[1], False),
|
||||
]
|
||||
bridge_present = True
|
||||
padding_applied = (bridge == "on+padding")
|
||||
else: # RQ2 — bridge always off; the topology factor varies.
|
||||
if topo == "1house-N":
|
||||
houses = (HOUSES[0],)
|
||||
labels = _house_nodes(seed, matched_n, hops, HOUSES[0])
|
||||
roles = ["entry", "relay", "exit"][:hops]
|
||||
hop_specs = [HopSpec(i, roles[i], HOUSES[0], labels[i], False)
|
||||
for i in range(hops)]
|
||||
elif topo == "bridge-federated":
|
||||
a = _house_nodes(seed, house_size, 1, HOUSES[0], salt=0)[0]
|
||||
b = _house_nodes(seed, house_size, 1, HOUSES[1], salt=0xB)[0]
|
||||
hop_specs = [
|
||||
HopSpec(0, "entry", HOUSES[0], a, False),
|
||||
HopSpec(1, "bridge", "bridge", _bridge_label(seed), True),
|
||||
HopSpec(2, "exit", HOUSES[1], b, False),
|
||||
]
|
||||
houses = (HOUSES[0], HOUSES[1])
|
||||
bridge_present = True
|
||||
elif topo == "directory-federated":
|
||||
directory = _build_directory(seed, house_size, n_houses)
|
||||
path = directory.select_federated_path(seed, hops=hops, min_houses=2)
|
||||
roles = ["entry", "relay", "exit"][:hops]
|
||||
hop_specs = [HopSpec(i, roles[i], house, _fingerprint_of(pub), False)
|
||||
for i, (pub, house) in enumerate(path)]
|
||||
houses = tuple(sorted({h.house for h in hop_specs}))
|
||||
else:
|
||||
raise ValueError(f"assembler: unknown RQ2 topology {topo!r}")
|
||||
|
||||
spec = CircuitSpec(
|
||||
cell_id=cell.cell_id, rq=cell.rq, seed=seed, engine=engine,
|
||||
topology=topo, bridge_present=bridge_present, padding_applied=padding_applied,
|
||||
matched_n=matched_n, houses=houses, hops=tuple(hop_specs),
|
||||
)
|
||||
# Federated cells must genuinely span ≥ 2 houses (split-knowledge, RQ2).
|
||||
if topo in ("bridge-federated", "directory-federated") and spec.span_houses() < 2:
|
||||
raise ValueError(f"assembler: {cell.cell_id} failed the >=2-house span")
|
||||
return spec
|
||||
|
||||
|
||||
def assemble_all(cells, run_index: int = 0, *, engine: str = "docker") -> Dict[str, CircuitSpec]:
|
||||
"""Assemble one representative circuit per cell (at ``run_index``) using the
|
||||
frozen seed rule. Returns ``cell_id -> CircuitSpec``."""
|
||||
from cmd_chat.sor.battery import derive_seed
|
||||
|
||||
return {c.cell_id: assemble(c, derive_seed(c.cell_id, run_index), engine=engine)
|
||||
for c in cells}
|
||||
@@ -271,3 +271,79 @@ def dry_pass(out_root: Path, *, runs: int = 2) -> Dict:
|
||||
if not path.exists():
|
||||
path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
return summary
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Assembler dry-check (fixtures only — proves each cell is a REAL, distinct,
|
||||
# isolation-gated live circuit, NOT the plain 3-hop control). Builds plans only:
|
||||
# opens no socket, moves no traffic, stands up no engine.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def assembler_dry_check(out_root: Path, run_index: int = 0, *, engine: str = "docker") -> Dict:
|
||||
"""Assemble every runnable §2 cell into its condition-encoding CircuitSpec and
|
||||
verify, on FIXTURES: (a) all 6 cells build a valid isolated ForwarderPlan for
|
||||
every hop (``engine != local``); (b) the 6 fingerprints are distinct (each cell
|
||||
is a genuinely different circuit, not the plain control); (c) re-assembling the
|
||||
same (cell, seed) reproduces its fingerprint (determinism); (d) the RQ1 bridge
|
||||
arms actually insert a bridge hop (+padding flag on on+padding); (e) the RQ2
|
||||
federation topologies genuinely span >= 2 houses. Plans only — no traffic, no
|
||||
engine. Writes a write-once report under ``out_root`` (kept OUT of any
|
||||
confirmatory data dir). Returns the summary."""
|
||||
from cmd_chat.sor.assembler import assemble
|
||||
|
||||
out_root = Path(out_root)
|
||||
out_root.mkdir(parents=True, exist_ok=True)
|
||||
cells = enumerate_cells()
|
||||
|
||||
per_cell: List[Dict] = []
|
||||
fingerprints: List[str] = []
|
||||
for c in cells:
|
||||
seed = derive_seed(c.cell_id, run_index)
|
||||
spec = assemble(c, seed, engine=engine)
|
||||
again = assemble(c, seed, engine=engine) # determinism: same (cell, seed)
|
||||
fingerprints.append(spec.fingerprint())
|
||||
per_cell.append({
|
||||
"cell_id": c.cell_id,
|
||||
"rq": c.rq,
|
||||
"seed": seed,
|
||||
"fingerprint": spec.fingerprint(),
|
||||
"isolation_gated": spec.isolation_gated(),
|
||||
"reproduces": spec.fingerprint() == again.fingerprint(),
|
||||
"bridge_present": spec.bridge_present,
|
||||
"padding_applied": spec.padding_applied,
|
||||
"span_houses": spec.span_houses(),
|
||||
"topology": spec.topology,
|
||||
})
|
||||
|
||||
# Condition-encoding expectations (each cell is REAL, not the plain control).
|
||||
def _cell(pred) -> Dict:
|
||||
return next(r for r in per_cell if pred(r["cell_id"]))
|
||||
|
||||
rq1_on = _cell(lambda i: i.endswith("bridge=on"))
|
||||
rq1_pad = _cell(lambda i: i.endswith("bridge=on+padding"))
|
||||
rq2_bridge_fed = _cell(lambda i: i.endswith("topo=bridge-federated"))
|
||||
rq2_dir_fed = _cell(lambda i: i.endswith("topo=directory-federated"))
|
||||
|
||||
summary = {
|
||||
"schema": "sor-assembler-dry/1",
|
||||
"run_index": run_index,
|
||||
"engine": engine,
|
||||
"n_cells": len(per_cell),
|
||||
"all_isolation_gated": all(r["isolation_gated"] for r in per_cell),
|
||||
"all_reproduce": all(r["reproduces"] for r in per_cell),
|
||||
"distinct_fingerprints": len(set(fingerprints)) == len(per_cell),
|
||||
"rq1_bridge_arms_live": rq1_on["bridge_present"] and rq1_pad["bridge_present"],
|
||||
"rq1_padding_arm_live": rq1_pad["padding_applied"] and not rq1_on["padding_applied"],
|
||||
"rq2_federation_spans_2plus": (
|
||||
rq2_bridge_fed["span_houses"] >= 2 and rq2_dir_fed["span_houses"] >= 2
|
||||
),
|
||||
"cells": per_cell,
|
||||
}
|
||||
summary["all_green"] = bool(
|
||||
summary["all_isolation_gated"] and summary["all_reproduce"]
|
||||
and summary["distinct_fingerprints"] and summary["rq1_bridge_arms_live"]
|
||||
and summary["rq1_padding_arm_live"] and summary["rq2_federation_spans_2plus"]
|
||||
)
|
||||
path = out_root / "assembler-dry.json"
|
||||
if not path.exists():
|
||||
path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
return summary
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""RQ1+RQ2 confirmatory battery — start-line preflight + guarded launcher.
|
||||
|
||||
This is the single operator entrypoint. Run with **no flags** it performs the
|
||||
*safe* start-line **preflight** and writes auditable artifacts, then prints a
|
||||
GO/NO-GO summary and the exact launch command — it collects **no** confirmatory
|
||||
data:
|
||||
|
||||
* §5 instrument-validation gate re-confirmation (``gate.run_gate``);
|
||||
* grid inventory + containment pin (``grid.write_device_map``);
|
||||
* frozen §2 cell plan + randomized/interleaved schedule (``battery.write_cell_plan``);
|
||||
* a 1-cell × 2-run DRY provenance pass on fixtures (``battery.dry_pass``).
|
||||
|
||||
The live per-cell condition assembler is now wired (``sor.assembler``): each cell
|
||||
maps to a genuinely distinct, condition-encoding, isolation-gated circuit (RQ1
|
||||
bridge-on / on+padding arms actually insert a bridge hop + PADDING stream; RQ2
|
||||
bridge-/directory-federated topologies genuinely span >= 2 houses). The preflight
|
||||
proves this on FIXTURES (``battery.assembler_dry_check`` — plans only, no traffic).
|
||||
|
||||
The **immutable confirmatory data run on the real grid is still the human gate**
|
||||
(CLAUDE.md §Stop, GOAL envelope (b)). ``--operator-go`` is triple-locked — it
|
||||
requires the ``SOR_CONFIRMATORY_GO=1`` operator token, a verified frozen-prereg
|
||||
SHA-256, and an isolated engine — and even with all three plus a green preflight it
|
||||
**HOLDS on grid completion**: the confirmatory battery does not launch until the
|
||||
full physical grid is up (the operator is bringing the 3rd phone online). This
|
||||
launcher surfaces that hold; it never fabricates a confirmatory cell.
|
||||
|
||||
Containment is load-bearing: nothing here forwards real/third-party traffic,
|
||||
touches an external target, or runs a forwarder on the host.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from cmd_chat.sor import battery, gate, grid
|
||||
from cmd_chat.sor.forwarder import assert_isolated
|
||||
|
||||
# The frozen prereg (read-only source of truth) and its pinned SHA-256. A GO is
|
||||
# refused unless the on-disk prereg still hashes to this — no confirmatory run
|
||||
# against an unfrozen/edited prereg.
|
||||
FROZEN_PREREG = Path.home() / "coding/sci-method/stages/03-design/output/sor-consent-prereg.md"
|
||||
FROZEN_PREREG_SHA256 = "f22331a72e0d0ccf38b787e63acabbe9d666456ec76076787a6d545c3193425b"
|
||||
|
||||
OPERATOR_TOKEN_ENV = "SOR_CONFIRMATORY_GO"
|
||||
|
||||
|
||||
def _ts() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
|
||||
def verify_freeze() -> bool:
|
||||
"""True iff the on-disk frozen prereg still matches its pinned SHA-256."""
|
||||
try:
|
||||
got = hashlib.sha256(FROZEN_PREREG.read_bytes()).hexdigest()
|
||||
except OSError:
|
||||
return False
|
||||
return got == FROZEN_PREREG_SHA256
|
||||
|
||||
|
||||
def preflight(out_dir: Path, *, order_seed: int = battery.S0, allow_live_gate: bool = True) -> Dict[str, Any]:
|
||||
"""Run the safe start-line preflight and write artifacts under ``out_dir``.
|
||||
Collects no confirmatory data. Returns a summary with a GO/NO-GO verdict."""
|
||||
out_dir = Path(out_dir)
|
||||
g = gate.run_gate(out_dir / "gate", allow_live=allow_live_gate)
|
||||
dm = grid.write_device_map(out_dir / "grid")
|
||||
plan_path = battery.write_cell_plan(out_dir / "plan", order_seed=order_seed)
|
||||
dp = battery.dry_pass(out_dir / "dry", runs=2)
|
||||
asm = battery.assembler_dry_check(out_dir / "assembler")
|
||||
|
||||
ready = bool(
|
||||
g["all_green"]
|
||||
and dm["topology_matchedN_honourable"]
|
||||
and dp["all_sha_match"] and dp["all_seed_reproduces"] and dp["distinct_seeds"]
|
||||
and asm["all_green"]
|
||||
and verify_freeze()
|
||||
)
|
||||
return {
|
||||
"generated_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"out_dir": str(out_dir),
|
||||
"gate_all_green": g["all_green"],
|
||||
"gate_offline_green": g["offline_items_green"],
|
||||
"grid_reachable": dm["reachable_count"],
|
||||
"grid_engine_hosts": dm["isolated_engine_host_count"],
|
||||
"grid_down": dm["devices_down"],
|
||||
"grid_honourable": dm["topology_matchedN_honourable"],
|
||||
"grid_full": len(dm["devices_down"]) == 0,
|
||||
"cell_plan": str(plan_path),
|
||||
"dry_ok": dp["all_sha_match"] and dp["all_seed_reproduces"] and dp["distinct_seeds"],
|
||||
"assembler_ok": asm["all_green"],
|
||||
"assembler_distinct": asm["distinct_fingerprints"],
|
||||
"assembler_isolation_gated": asm["all_isolation_gated"],
|
||||
"freeze_ok": verify_freeze(),
|
||||
"preflight_ready": ready,
|
||||
}
|
||||
|
||||
|
||||
def _print_summary(s: Dict[str, Any]) -> None:
|
||||
print(f"[preflight] artifacts -> {s['out_dir']}")
|
||||
print(f"[preflight] gate all_green={s['gate_all_green']} "
|
||||
f"(offline={s['gate_offline_green']})")
|
||||
print(f"[preflight] grid reachable={s['grid_reachable']} "
|
||||
f"engine_hosts={s['grid_engine_hosts']} down={s['grid_down']} "
|
||||
f"honourable={s['grid_honourable']}")
|
||||
print(f"[preflight] dry_ok={s['dry_ok']} freeze_ok={s['freeze_ok']}")
|
||||
print(f"[preflight] assembler_ok={s['assembler_ok']} "
|
||||
f"(distinct={s['assembler_distinct']} isolation_gated={s['assembler_isolation_gated']})")
|
||||
print(f"[preflight] grid_full={s['grid_full']} READY={s['preflight_ready']}")
|
||||
|
||||
|
||||
def _refuse_go(reason: str) -> int:
|
||||
print(f"[GO REFUSED] {reason}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
prog="python -m cmd_chat.sor.confirmatory_run",
|
||||
description="RQ1+RQ2 confirmatory battery: safe preflight by default; "
|
||||
"--operator-go is the human-gated immutable data run.",
|
||||
)
|
||||
ap.add_argument("--out", default=f"output/sor-confirmatory/{_ts()}",
|
||||
help="preflight artifact dir (default: timestamped)")
|
||||
ap.add_argument("--order-seed", type=int, default=battery.S0,
|
||||
help="measurement-side schedule ordering seed (default S0)")
|
||||
ap.add_argument("--engine", default="docker", help="isolated engine (default docker)")
|
||||
ap.add_argument("--operator-go", action="store_true",
|
||||
help="attempt the human-gated confirmatory data run (triple-locked)")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
out_dir = Path(args.out)
|
||||
summary = preflight(out_dir, order_seed=args.order_seed)
|
||||
_print_summary(summary)
|
||||
|
||||
go_cmd = (f"{OPERATOR_TOKEN_ENV}=1 python -m cmd_chat.sor.confirmatory_run "
|
||||
f"--operator-go --engine {args.engine}")
|
||||
|
||||
if not args.operator_go:
|
||||
print("\n[held] preflight only — no confirmatory data collected.")
|
||||
print(f"[held] the immutable data run is the human gate. GO command:\n {go_cmd}")
|
||||
return 0
|
||||
|
||||
# --- triple-locked GO path (human gate) --------------------------------- #
|
||||
if os.environ.get(OPERATOR_TOKEN_ENV) != "1":
|
||||
return _refuse_go(f"operator token missing (set {OPERATOR_TOKEN_ENV}=1)")
|
||||
if not verify_freeze():
|
||||
return _refuse_go("frozen prereg SHA-256 mismatch — refusing to run against an unfrozen prereg")
|
||||
try:
|
||||
assert_isolated(args.engine)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return _refuse_go(f"containment: {exc}")
|
||||
if not summary["preflight_ready"]:
|
||||
return _refuse_go("preflight not fully green — resolve NO-GO items before a data run")
|
||||
|
||||
# Triple-lock passes and the live per-cell condition assembler is wired +
|
||||
# fixture-validated (assembler_ok). The one remaining gate is physical: the
|
||||
# confirmatory battery HOLDS until the full grid is up. The operator is
|
||||
# bringing the 3rd phone online; until every device is reachable this launcher
|
||||
# refuses to launch a data run on a degraded grid rather than fabricate cells.
|
||||
if not summary["grid_full"]:
|
||||
return _refuse_go(
|
||||
"grid completing: confirmatory battery holds until the full physical "
|
||||
f"grid is up (devices down: {summary['grid_down']}). The live per-cell "
|
||||
"assembler is wired + fixture-validated; the operator is bringing the "
|
||||
"3rd phone online. Re-run --operator-go once the grid is complete."
|
||||
)
|
||||
|
||||
# Full grid + all three locks + green preflight: the immutable confirmatory
|
||||
# data run is still the human's to launch. This launcher surfaces readiness;
|
||||
# the physical data collection is initiated by the operator, not fabricated.
|
||||
return _refuse_go(
|
||||
"human gate: grid complete and preflight green — the immutable confirmatory "
|
||||
"data run is the operator's to initiate on the live grid. This launcher "
|
||||
"does not fabricate confirmatory cells."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Instrument-validation gate re-confirmation (prereg §5) for the RQ1+RQ2 path.
|
||||
|
||||
Re-runs the six boolean gate items and writes an auditable ``gate-report.json``.
|
||||
This is calibration/validation on **fixtures only** — it collects no
|
||||
confirmatory-cell data, so it is legitimate start-line work under the freeze:
|
||||
|
||||
1. 3-hop e2e delivery + per-hop pcap checksums (R4, live isolated docker);
|
||||
2. seeded reproducibility — same seed → identical circuit-build sequence (R1);
|
||||
3. correlator calibration — known-linked AUC≈1, known-unlinked AUC≈0.5 (R7);
|
||||
4. entropy estimator returns H = log2(N) for N equiprobable senders (R7);
|
||||
5. forwarders isolated-engine-only — ``assert engine != local`` or refuse (R4);
|
||||
6. provenance integrity — replayed fixture events SHA-256 matches the sealed
|
||||
manifest; append-only (R2/R3).
|
||||
|
||||
Item 1 stands up isolated **docker** containers and moves only self-generated
|
||||
fixture bytes between our own containers (containment-safe, always torn down).
|
||||
Items 2-6 are pure offline checks. Nothing here forwards real/third-party
|
||||
traffic, touches an external target, or runs a forwarder on the host.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import shutil
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from cmd_chat.sor import events as sor_events
|
||||
from cmd_chat.sor import forwarder as sor_forwarder
|
||||
from cmd_chat.sor.analysis.detectors import (
|
||||
bridge_correlation_auc,
|
||||
shannon_entropy_bits,
|
||||
synthetic_bridge_fixture,
|
||||
)
|
||||
from cmd_chat.sor.config import bringup
|
||||
from cmd_chat.sor.provenance import Node, RunManifest, validate_manifest
|
||||
|
||||
# Calibration tolerances (fixtures, ground truth known by construction).
|
||||
_AUC_LINKED_MIN = 0.99 # known-linked control must separate near-perfectly.
|
||||
_AUC_UNLINKED_TOL = 0.05 # known-unlinked mean must sit within 0.05 of chance.
|
||||
_ENTROPY_TOL = 1e-9 # H = log2(N) must hold to floating-point exactness.
|
||||
_UNLINKED_SEEDS = 40 # seeds averaged for the unlinked-chance calibration.
|
||||
|
||||
|
||||
@dataclass
|
||||
class GateItem:
|
||||
"""One §5 gate item's re-confirmation outcome."""
|
||||
|
||||
n: int
|
||||
name: str
|
||||
passed: bool
|
||||
status: str # "green" | "red" | "unavailable"
|
||||
evidence: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _fixture_pubkey(seed: int, role: str) -> str:
|
||||
raw = hashlib.sha256(f"sor-gate|{seed}|{role}".encode()).digest() # 32 bytes
|
||||
import base64
|
||||
|
||||
return base64.b64encode(raw).decode()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Item 1 — live 3-hop e2e delivery (isolated docker).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _item1_e2e(out_root: Path, seed: int, *, allow_live: bool) -> GateItem:
|
||||
"""Stand up a 3-hop nested-SSH docker circuit, deliver a seed-deterministic
|
||||
self-payload end-to-end, and require per-hop pcap checksums. Marked
|
||||
``unavailable`` (not ``red``) when the docker engine/image is absent — a
|
||||
missing execution boundary is not a failed instrument."""
|
||||
have_docker = shutil.which("docker") is not None
|
||||
if not (allow_live and have_docker):
|
||||
return GateItem(1, "3-hop e2e delivery + pcap checksums", False, "unavailable",
|
||||
{"reason": "docker engine/image not available or live run disabled",
|
||||
"have_docker": have_docker})
|
||||
try:
|
||||
res = sor_forwarder.run_circuit_fixture(seed, engine="docker", hops=3,
|
||||
out_root=out_root)
|
||||
except Exception as exc: # noqa: BLE001 - report, never crash the gate
|
||||
return GateItem(1, "3-hop e2e delivery + pcap checksums", False, "red",
|
||||
{"error": f"{type(exc).__name__}: {exc}"})
|
||||
pcaps_ok = len(res.pcap_sha256) == 3 and all(res.pcap_sha256.values())
|
||||
passed = bool(res.delivered and pcaps_ok
|
||||
and res.received_sha256 == res.payload_sha256)
|
||||
return GateItem(1, "3-hop e2e delivery + pcap checksums", passed,
|
||||
"green" if passed else "red",
|
||||
{"run_id": res.run_id, "delivered": res.delivered,
|
||||
"payload_sha256": res.payload_sha256,
|
||||
"received_sha256": res.received_sha256,
|
||||
"pcap_sha256": {str(k): v for k, v in res.pcap_sha256.items()},
|
||||
"events_sha256": res.events_sha256})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Item 2 — seeded reproducibility (R1).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _item2_reproducible(*, pool: int = 5, hops: int = 3, rebuilds: int = 3) -> GateItem:
|
||||
seed = 0xC0FFEE
|
||||
a = bringup(seed, pool, hops, rebuilds)
|
||||
b = bringup(seed, pool, hops, rebuilds)
|
||||
other = bringup(seed ^ 0x1, pool, hops, rebuilds)
|
||||
passed = a == b and a != other
|
||||
return GateItem(2, "seeded circuit-build reproducibility", passed,
|
||||
"green" if passed else "red",
|
||||
{"seed": seed, "identical_on_replay": a == b,
|
||||
"differs_on_other_seed": a != other, "sequence": a})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Item 3 — correlator calibration (R7).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _item3_correlator() -> GateItem:
|
||||
linked_aucs = [bridge_correlation_auc(*synthetic_bridge_fixture(s, linked=True))
|
||||
for s in range(_UNLINKED_SEEDS)]
|
||||
unlinked_aucs = [bridge_correlation_auc(*synthetic_bridge_fixture(s, linked=False))
|
||||
for s in range(_UNLINKED_SEEDS)]
|
||||
linked_min = min(linked_aucs)
|
||||
unlinked_mean = sum(unlinked_aucs) / len(unlinked_aucs)
|
||||
passed = linked_min >= _AUC_LINKED_MIN and abs(unlinked_mean - 0.5) <= _AUC_UNLINKED_TOL
|
||||
return GateItem(3, "correlator calibration (linked≈1 / unlinked≈0.5)", passed,
|
||||
"green" if passed else "red",
|
||||
{"linked_auc_min": linked_min, "unlinked_auc_mean": unlinked_mean,
|
||||
"n_seeds": _UNLINKED_SEEDS, "linked_floor": _AUC_LINKED_MIN,
|
||||
"unlinked_tol": _AUC_UNLINKED_TOL})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Item 4 — entropy estimator H = log2(N) (R7).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _item4_entropy() -> GateItem:
|
||||
checks = []
|
||||
ok = True
|
||||
for n in (2, 4, 8, 16, 64):
|
||||
h = shannon_entropy_bits([1] * n) # N equiprobable senders
|
||||
exact = abs(h - math.log2(n)) <= _ENTROPY_TOL
|
||||
ok = ok and exact
|
||||
checks.append({"N": n, "H": h, "log2N": math.log2(n), "exact": exact})
|
||||
return GateItem(4, "entropy estimator H = log2(N)", ok,
|
||||
"green" if ok else "red", {"checks": checks, "tol": _ENTROPY_TOL})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Item 5 — isolated-engine assertion (R4).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _item5_isolation() -> GateItem:
|
||||
refused_local = False
|
||||
refused_unknown = False
|
||||
accepts_docker = False
|
||||
try:
|
||||
sor_forwarder.assert_isolated("local")
|
||||
except sor_forwarder.ContainmentError:
|
||||
refused_local = True
|
||||
try:
|
||||
sor_forwarder.assert_isolated("host-native")
|
||||
except sor_forwarder.ContainmentError:
|
||||
refused_unknown = True
|
||||
try:
|
||||
sor_forwarder.assert_isolated("docker")
|
||||
accepts_docker = True
|
||||
except sor_forwarder.ContainmentError:
|
||||
accepts_docker = False
|
||||
passed = refused_local and refused_unknown and accepts_docker
|
||||
return GateItem(5, "forwarders isolated-engine-only (assert engine != local)", passed,
|
||||
"green" if passed else "red",
|
||||
{"refused_local": refused_local, "refused_unknown": refused_unknown,
|
||||
"accepts_docker": accepts_docker})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Item 6 — provenance integrity (R2/R3).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _item6_provenance(out_root: Path, *, pool: int = 5, hops: int = 3) -> GateItem:
|
||||
seed = 0x5EED
|
||||
# Unique run subdir per invocation so each produces fresh write-once artifacts
|
||||
# (and run_gate stays safely repeatable into a reused out_dir).
|
||||
run_id = f"gate6-{seed:016x}-{uuid.uuid4().hex[:8]}"
|
||||
run_dir = Path(out_root) / run_id
|
||||
nodes = [Node(role=r, persona_pub_b64=_fixture_pubkey(seed, r), engine="docker")
|
||||
for r in ("host", "hop", "hop")]
|
||||
manifest = RunManifest(run_id=run_id, sor_seed=seed, topology="1house",
|
||||
selector="static", churn_schedule_id="none", nodes=nodes,
|
||||
engine_kind="docker", worktree_root=Path.cwd())
|
||||
sealed = sor_events.replay_and_seal(run_dir, manifest, pool=pool, hops=hops, rebuilds=1)
|
||||
validate_manifest(sealed) # raises on any schema violation
|
||||
recomputed = hashlib.sha256((run_dir / "events.jsonl").read_bytes()).hexdigest()
|
||||
sha_match = sealed["events"]["sha256"] == recomputed
|
||||
|
||||
# Append-only / immutability: re-sealing the same manifest is refused.
|
||||
reseal_refused = False
|
||||
try:
|
||||
from cmd_chat.sor.provenance import seal_manifest
|
||||
|
||||
seal_manifest(run_dir, recomputed)
|
||||
except ValueError:
|
||||
reseal_refused = True
|
||||
|
||||
passed = sha_match and reseal_refused
|
||||
return GateItem(6, "provenance integrity (events SHA == manifest; append-only)", passed,
|
||||
"green" if passed else "red",
|
||||
{"run_id": run_id, "events_sha256": recomputed,
|
||||
"manifest_events_sha256": sealed["events"]["sha256"],
|
||||
"sha_match": sha_match, "reseal_refused": reseal_refused})
|
||||
|
||||
|
||||
def run_gate(out_dir: Path, *, allow_live: bool = True, e2e_seed: int = 0xA11CE) -> Dict[str, Any]:
|
||||
"""Re-confirm all six §5 gate items and write ``gate-report.json`` (write-once)
|
||||
under ``out_dir``. Returns the report dict. ``all_green`` is True only if every
|
||||
item is green; item 1 may be ``unavailable`` when no isolated engine is present,
|
||||
which is reported distinctly from a ``red`` failure."""
|
||||
out_dir = Path(out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
items: List[GateItem] = [
|
||||
_item1_e2e(out_dir, e2e_seed, allow_live=allow_live),
|
||||
_item2_reproducible(),
|
||||
_item3_correlator(),
|
||||
_item4_entropy(),
|
||||
_item5_isolation(),
|
||||
_item6_provenance(out_dir),
|
||||
]
|
||||
all_green = all(it.status == "green" for it in items)
|
||||
offline_green = all(it.status == "green" for it in items if it.n != 1)
|
||||
report = {
|
||||
"schema": "sor-gate-report/1",
|
||||
"scope": "RQ1+RQ2 lead-paper path (prereg §5 instrument-validation gate)",
|
||||
"generated_utc": _utc_now_iso(),
|
||||
"all_green": all_green,
|
||||
"offline_items_green": offline_green,
|
||||
"items": [{"n": it.n, "name": it.name, "passed": it.passed,
|
||||
"status": it.status, "evidence": it.evidence} for it in items],
|
||||
}
|
||||
path = out_dir / "gate-report.json"
|
||||
if not path.exists():
|
||||
path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
report["_report_path"] = str(path)
|
||||
return report
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Grid inventory + containment pin for the RQ1+RQ2 confirmatory battery.
|
||||
|
||||
Pins the node-role → device mapping for the lab grid (2 phones + laptop) and
|
||||
probes, at call time, which devices are SSH-reachable and which can host an
|
||||
**isolated engine** (docker). Writes an auditable ``device-map.json``.
|
||||
|
||||
Containment note (load-bearing): every SOR forwarder/hop runs inside an isolated
|
||||
engine (docker container), never on a phone or the host directly. The physical
|
||||
phones are *distribution* options for where those isolated containers live; the
|
||||
operator-blessed fallback for the confirmatory RQ1/RQ2 path is isolated docker
|
||||
containers co-located on the docker host (as used for gate item 1). So the grid
|
||||
being partly degraded does not by itself collapse the topology — but any inability
|
||||
to honour the §2 topology / §6 matched-N with isolated nodes is a STOP-and-flag.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Device:
|
||||
"""A pinned grid device and the house-role it plays in the confirmatory design."""
|
||||
|
||||
name: str # ssh alias / stable id
|
||||
kind: str # "phone" | "laptop"
|
||||
arch: str # "aarch64" | "x86_64"
|
||||
can_host_engine: bool # can run an isolated docker engine locally?
|
||||
house_role: str # design role (e.g. "house-A host / hop pool")
|
||||
|
||||
|
||||
# Pinned inventory (lab-only, all ours — CLAUDE.md §Containment). tril + fp6 are
|
||||
# the two phones; laptop is the x86_64 docker host. Only devices that can host an
|
||||
# isolated docker engine may run a forwarder; phones without docker are consent
|
||||
# endpoints / distribution targets, never a bare host forwarder.
|
||||
GRID_INVENTORY: List[Device] = [
|
||||
Device("laptop", "laptop", "x86_64", True, "house-A docker host + hop pool"),
|
||||
Device("fp6", "phone", "aarch64", False, "house-B consenting node (phone)"),
|
||||
Device("tril", "phone", "aarch64", False, "house-C consenting node (Termux, no docker)"),
|
||||
]
|
||||
|
||||
|
||||
def _probe_ssh(alias: str, timeout: int = 8) -> bool:
|
||||
"""Best-effort SSH reachability probe (BatchMode, short timeout). Never raises."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
|
||||
"-o", "StrictHostKeyChecking=accept-new", alias, "true"],
|
||||
capture_output=True, timeout=timeout,
|
||||
)
|
||||
return r.returncode == 0
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def _probe_remote_docker(alias: str, timeout: int = 12) -> bool:
|
||||
"""Best-effort probe of an isolated docker engine on a remote device. Never raises."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
|
||||
"-o", "StrictHostKeyChecking=accept-new", alias,
|
||||
"docker version --format '{{.Server.Version}}'"],
|
||||
capture_output=True, timeout=timeout,
|
||||
)
|
||||
return r.returncode == 0 and bool(r.stdout.strip())
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def _probe_local_docker() -> Optional[str]:
|
||||
"""Local docker server version if a daemon is up, else None. Never raises."""
|
||||
if shutil.which("docker") is None:
|
||||
return None
|
||||
try:
|
||||
r = subprocess.run(["docker", "version", "--format", "{{.Server.Version}}"],
|
||||
capture_output=True, timeout=12)
|
||||
return r.stdout.decode().strip() if r.returncode == 0 else None
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def probe_grid() -> Dict[str, Any]:
|
||||
"""Probe reachability + isolated-engine availability across the pinned grid.
|
||||
Pure I/O, no traffic, no forwarder — a connectivity/capability snapshot only."""
|
||||
local_docker = _probe_local_docker()
|
||||
devices: List[Dict[str, Any]] = []
|
||||
for d in GRID_INVENTORY:
|
||||
reachable = _probe_ssh(d.name)
|
||||
# An isolated engine is available on this device if it is the local docker
|
||||
# host (laptop here) or a reachable device advertising a docker daemon.
|
||||
if d.name == "laptop":
|
||||
engine_ok = local_docker is not None
|
||||
engine_ver = local_docker
|
||||
elif reachable and d.can_host_engine:
|
||||
engine_ok = _probe_remote_docker(d.name)
|
||||
engine_ver = "remote-docker" if engine_ok else None
|
||||
else:
|
||||
engine_ok = False
|
||||
engine_ver = None
|
||||
devices.append({
|
||||
**asdict(d),
|
||||
"ssh_reachable": reachable,
|
||||
"isolated_engine_available": engine_ok,
|
||||
"engine_version": engine_ver,
|
||||
})
|
||||
return {"local_docker_version": local_docker, "devices": devices}
|
||||
|
||||
|
||||
def write_device_map(out_dir: Path) -> Dict[str, Any]:
|
||||
"""Write ``device-map.json`` (write-once) pinning the node-role→device map and
|
||||
the current reachability/engine snapshot, plus a containment + matched-N
|
||||
assessment. Returns the document."""
|
||||
out_dir = Path(out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
snap = probe_grid()
|
||||
|
||||
engine_hosts = [d for d in snap["devices"] if d["isolated_engine_available"]]
|
||||
reachable = [d for d in snap["devices"] if d["ssh_reachable"]]
|
||||
down = [d["name"] for d in snap["devices"] if not d["ssh_reachable"]]
|
||||
# RQ1/RQ2 hops run as isolated docker containers; a single docker host can
|
||||
# host >=3 distinct containerised nodes (operator-blessed, gate item 1). So
|
||||
# the topology/matched-N is honourable iff at least one isolated engine exists.
|
||||
topology_honourable = len(engine_hosts) >= 1
|
||||
|
||||
doc = {
|
||||
"schema": "sor-device-map/1",
|
||||
"scope": "RQ1+RQ2 lead-paper grid pin (CLAUDE.md §Containment)",
|
||||
"generated_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"local_docker_version": snap["local_docker_version"],
|
||||
"devices": snap["devices"],
|
||||
"reachable_count": len(reachable),
|
||||
"isolated_engine_host_count": len(engine_hosts),
|
||||
"degraded": bool(down),
|
||||
"devices_down": down,
|
||||
"topology_matchedN_honourable": topology_honourable,
|
||||
"containment": {
|
||||
"hops_run_in": "isolated docker containers only (never a phone/host forwarder)",
|
||||
"external_target": "none",
|
||||
"live_vm_churn_on_rq1_rq2": "none (RQ1/RQ2 use no churn; churn_schedule_id=none)",
|
||||
"self_traffic_only": True,
|
||||
},
|
||||
"matched_N_note": (
|
||||
"RQ1/RQ2 isolated hops are containerised on the docker host (>=3 distinct "
|
||||
"containers = distinct nodes). Physical-phone distribution is optional; the "
|
||||
"confirmatory matched-N (single-house N = federated total consenting nodes) is "
|
||||
"pinned from the containerised node count at run time and recorded per manifest."
|
||||
),
|
||||
"assessment": (
|
||||
"GO on isolated docker host" if topology_honourable
|
||||
else "STOP: no isolated engine available — topology/matched-N cannot be honoured"
|
||||
),
|
||||
}
|
||||
path = out_dir / "device-map.json"
|
||||
if not path.exists():
|
||||
path.write_text(json.dumps(doc, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
doc["_report_path"] = str(path)
|
||||
return doc
|
||||
Reference in New Issue
Block a user