diff --git a/docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json b/docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json index 079c8a4..82e2931 100644 --- a/docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json +++ b/docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json @@ -3707,7 +3707,7 @@ "template": "P23 life observation: checks, failures", "required": true, "fixed_check_count_used": false, - "source_sha256_at_schema_audit": "b9afa66ec73d3a5cadf86f9a71f0d13f5b085c3cc9a534dfe29b2bed0e2e76a0", + "source_sha256_at_schema_audit": "cc7049e1c3919585caeefd1747be12fbf3fa52141fcdb980929c8a16fd7e2ee1", "authority": { "path": "docs/spec/acceptance/P23-03.json", "scope": "component and read-only observation only; native unexecuted; whole unit blocked" diff --git a/docs/plans/original-completion-2026-10-02/execution/p23-03/WORKER_STATUS.md b/docs/plans/original-completion-2026-10-02/execution/p23-03/WORKER_STATUS.md index d9c13e9..0303b76 100644 --- a/docs/plans/original-completion-2026-10-02/execution/p23-03/WORKER_STATUS.md +++ b/docs/plans/original-completion-2026-10-02/execution/p23-03/WORKER_STATUS.md @@ -1,3 +1,205 @@ +# P23-03 worker status — static-complete submission + +Contract base: `860136f7099f5e3aee99da5798a627a77f2d8eea`. +This finalization preserves every pre-existing modified and untracked file. +Static-complete is supported by the wiring inspection and local checks below. +No acceptance claim is made. No Godot, native, OS collector, Runner, full +regression, git write, branch change, or other worker was used. + +## Current wiring confirmed by git diff and rg only + +Line numbers refer to the current candidate. + +- Checker `tools/check_p23_life_observation_evidence.py`: `check` at 1189 + reaches `inspect_native_phases` (1162) at 1215 and `inspect_stream` (950) + at 1220/1226. The stream inspection invokes `compare_rest` (531) at 968, + `compare_motion` (738) at 974, and `compare_actor_geometry` (800) at 975, + alongside actor, room, queue, traffic, goods, badge and cabin checks. +- `check` reaches `inspect_os_receipt` (1038), `inspect_os_requests` (1092), + and `inspect_resume` (1070) at 1249, 1250 and 1257. + Request inspection calls `screenshot_pixels` (184) at 1143, which invokes + `inspect_conversion` (158) at 191 for JPEG evidence; rectangle/pixel + checking follows at 1154. +- `check` builds executed check sets at 1270–1272, then calls `BLOCKERS` + at 1273, reaching `condition_matrix` (982) through 1000. All original + seven CONDITIONS and six CATEGORIES (room, queue, EV, staff, goods, + business state, represented by the key `business`) remain required. + Missing executed check names produce pending/deficits; no fixed-true matrix. +- Collector `tools/run_p23_life_observation.py`: `collect_os` (110) + invokes `collect_screenshots` (26) at 186. JPEG collection invokes + `convert_screenshot` (73) at 55; conversion calls `inspect_conversion` + at 107. Raw CUA bytes and tool-call bindings are retained separately from + converted PNG and conversion receipts. This wiring was inspected, not run. +- `diagnostic_stream.gd` captures hash-addressed `rest_audit` and `motion` + at 70–78. `rest_audit` (89) records selection inputs, route/cost probes, + locks/transit and comparison-only Runtime output. No stored-rest field was + introduced. The source-grounded correction supersedes the old stored-state + proposal below. MotionObserver retains two draw frames, not whole Runtime + state per frame. +- Native route: `tests/ui/test_p23_life_observation.gd` at 18–21 invokes + `layout_comparison.gd`; the runner invokes that UI test at 309 for A/B. + Layout comparison preloads diagnostics at 5 and starts watching at 65. +- Normal OS route is separate from the native UI component test: runner + RECORDER at 24 is copied/launched at 145/152; `passive_os_recorder.gd` + preloads `passive_observation.gd` at 2 and calls its inherited run at 6. + `passive_observation.gd` extends `observation.gd` at 1; + `observation.gd` preloads diagnostics/layout comparison at 3–4 and + watches the real preview at 26. These fixtures are reached through the + OS runner, not directly preloaded by the native UI test. +- Existing mutation negatives cover conversion provenance, rest operands, + motion history, rectangle/pixel transforms, process/source/profile guards, + formal members/distinct PID/ACK, and every condition/category matrix cell. + No concrete missing call or negative was found; no code hardening was added. + +## Exact local commands and real results + +- `git status --short`, `git diff --stat`, and targeted `git diff --` + for the collector, UI test, diagnostic fixture and marker: inspected existing + changes without modifying git state. +- Targeted `rg -n` / `rg -n -A...` searches for function definitions, + calls, fixture preloads/extends, rest_audit/motion, CONDITIONS/CATEGORIES + and unittest methods in the three P23 tools and five P23 fixtures/UI test: + wiring results recorded above. No Runtime/source audit or Phase0 repetition. +- `python3 -m py_compile tools/run_p23_life_observation.py tools/check_p23_life_observation_evidence.py tools/test_p23_life_observation_evidence.py`: + initial attempt failed because the interpreter's default cache destination + under Library/Caches was not writable; no syntax failure was reported. +- `PYTHONPYCACHEPREFIX=docs/plans/original-completion-2026-10-02/execution/p23-03/.finalization-pycache python3 -m py_compile tools/run_p23_life_observation.py tools/check_p23_life_observation_evidence.py tools/test_p23_life_observation_evidence.py`: + PASS, exit 0 for all three tools. +- `python3 -B tools/test_p23_life_observation_evidence.py`: + **27 test methods run, 27 passed**, 0 failures/errors; reported 0.044 seconds. + These are synthetic checker tests, not native/OS evidence. +- `git diff --check`: PASS, exit 0; repeated after this status rewrite. +- Read-only `python3 -B -c` JSON/hash/path assertions using + `git show 860136f7099f5e3aee99da5798a627a77f2d8eea:`, + `git diff --name-only ` and + `git ls-files --others --exclude-standard`: PASS. + All old marker metadata/rows equal base; exactly one appended P23-03 + required source-authority entry; test SHA + `cc7049e1c3919585caeefd1747be12fbf3fa52141fcdb980929c8a16fd7e2ee1` + matches current bytes. suite-policy.json equals base byte-for-byte. + All 16 changed/untracked paths relative to base are allowed. +- The immutable spec SHA matches + `8568457df849ef587c731f072d77244ef5420fc4e1190c5069cfd6cfeea9c89f` + using the existing checker's `read_text().strip().encode()` convention. + An initial extra raw-byte SHA assertion failed (raw file SHA + `1a925e0d7f5c1c9f28670d29dd0fdc5aa48f4d5415946d6de4c15e1d83b5ed66`); + normalized verification passed and the spec has no diff against base. +- An initial inline validation here-document could not create its shell + temporary file; rerun via `python3 -B -c` succeeded. +- Removed only the temporary .finalization-pycache tree created by this run. + No pre-existing dirty/untracked material was removed. + +## Root work still pending — expected, not worker blockers + +Native Godot execution; actual OS collector with screenshots for both layouts +and all six categories, FRONT/BACK and ordinary pan/zoom; formal save and +distinct actual PID normal resume; one full exact-candidate **177-suite** +regression; independent Opus all-condition review. These must precede any +acceptance determination. Constructibility, visual correctness and same-demand +improvement have not been claimed from static checks. + +## Superseded history — retained verbatim + +The following earlier blocked statuses (including the 19-method and +"not yet wired" claims) describe previous candidates and are superseded by the +current wiring and results above. + +# Current RUN continuation — blocked on stored-rest-destination contract + +All seven pre-existing modified files and three untracked fixtures were retained. +No git writes, agents, Godot, collector execution, Runner or full regression. +This is not a genuine completed submission and makes no acceptance claim. +Root native/OS evidence remains **pending**, independently of this source blocker. + +Changes completed in this continuation: +- Removed mandatory left-button drag and its pan-change assertion. Camera + screenshots now use ordinary-wheel-zoom, pan-mode-button, move-mode-wheel-pan. + Existing non-Ctrl event observation is preserved. +- Added stdlib PNG decoding with CRC/chunk/end checks, RGB/RGBA and filters 0–4. + JPEG dimensions reuse the P08 helper; no JPEG decoder or PIL was added. +- Added conversion receipt inspection and Root-only convert_screenshot helper: + separate output, original CUA hash/call binding, sips executable SHA/argv, + output digests, decoded PNG and magic-derived dimension equality. No actual + sips conversion was run here. This helper is not yet wired into collect_os. +- Added four image test methods including all filters, CRC corruption, trailing + data, converter SHA/argv, raw CUA hash, dimensions and missing-conversion negatives. + +Actually executed: 19 checker unittest methods passed; py_compile passed for +the three P23 Python tools. Temporary compilation outputs were removed. +Synthetic image tests are checker tests, never native screenshot evidence. + +Concrete contract gap (targeted dependency inspection, not a repeated Phase0 audit): +- autoload/simulation_runtime.gd:13109–13125 computes get_staff_rest_report by + scanning facilities and selecting a minimum-cost route on every call. The + selected facility and eligibility are local variables, not stored state. +- autoload/simulation_runtime.gd:8783–8785 calls that query and passes only its + cell to advance_staff_commute. At :12656–12690 the movement function either + returns without movement or emits a STAFF_MOVED event with a target_cell; + it does not persist a selected rest facility ID or eligibility. Movement + events cannot supply a current destination for stationary/nonmoving staff. +- scripts/staff/staff_runtime.gd:282–315 serializes current_cell, assignment, + task and path_handle but no rest destination/eligibility. Runtime snapshot + assembly at :511–570 has no stored rest-selection model. PathService's + snapshot at scripts/world/path_service.gd:1606 stores path/actor projections, + not the selected rest facility and its eligibility. + +Thus recipe (3), "rest destination must equal the typed stored destination and +its eligibility; do not re-simulate AI", has no authoritative stored operand. +Calling get_staff_rest_report from a fixture would rerun selection, and copying +its output into a purported stored-state field would manufacture authority. +These Runtime/staff/path files are outside allowed_paths and remain unchanged. + +Read source SHA256: +- Runtime: 4696df4a77feb465a5a3ba8b806569f7bcbe1d03c88289acfb78a3928f307d67 +- StaffRuntime: 807a417bc7866c97cd6d8f5c1368b5480a2d9fd685a2a47a978e81482bf522b5 +- PathService: 447135729a1106639a6eee6754af3195c8a8b458ca26980ae91fa39d80dcfbd4 + +Minimal separate proposal for Root/Opus, not applied: authorize an authoritative +rest-decision record at the existing Runtime selection point, including selected +facility/cell, eligibility and decision time, with explicit invalidation and +snapshot/restore semantics. Alternatively Root must identify an existing typed +stored field that has those semantics. No worker relaxation of this gate. + +Remaining source implementation is explicit: collector conversion integration, +all row/rectangle bindings, motion history, distinct-PID/formal ACK checker, +dynamic matrix and the remaining requested negatives. Existing fail-closed +whole-unit behavior remains; no unconditional PASS or new fail stub was added. +The 30/88 denominator, seven conditions, six categories and old suite policies +are unchanged. Do not begin acceptance assuming only Root evidence is missing. + +--- +Previous status retained as history: + +# Repair from source 03100be — blocked, implementation incomplete + +Source checkout HEAD confirmed read-only: `03100beabeb55caac03f53db3d1f399b424df517`. +Contract base remains `860136f7099f5e3aee99da5798a627a77f2d8eea`. +No git writes, Godot, Runner, OS input, regression, or other workers were used. +No acceptance or partial credit is claimed. No out-of-scope defect or missing Runtime capability was established. + +Changes in this repair: +- Added registered `--verify-only` CLI, concrete missing-index deficits, immutable fixture/spec/contract/baseline/AGENTS pins and a normative P23 plan-section hash. Full-plan SHA is separate provenance only. Existing diagnostic/layout CLI modes retained. +- Removed fixture-side begin_leaving/despawn_guest calls; Runtime owns continued guest behavior. Added A/B native entry to the existing component test and updated the existing single marker row SHA. +- Added observation.gd extending P08 placement helpers unchanged, with real supply scenario commands/settings, common definition-derived construction budget, seed806042, relocated common cafeteria/kitchen at x6 to avoid declared B elevator footprint, sparse typed captures, normal save/load and whole formal-model comparisons. These construction prechecks have not been executed. +- Added passive request/ACK endpoint and recorder for four layout/role combinations, live FRONT/BACK buttons and PanModeButton, ordinary non-Ctrl wheel zoom/pan and ordinary pan drag only. No deferred P23-02 held-gesture credit. +- Replaced the collector stub with an adaptation of P08 collect/collect_os using run_all_tests helpers and owned gates, fresh outputs/profiles, native import/parse/layout phases, four OS roles and formal reader copies. Records the current interpreter PIL availability; raw screenshot bytes are left untouched. Collector has not been executed. +- Checker now inspects available native/OS streams, comparisons and basic process/source/profile guards, but explicitly fails closed for outstanding whole-unit gates. + +Local verification actually executed: 16 Python unittest cases PASS; ast.parse for the three changed Python tools PASS; one P23 marker row and exact source SHA PASS. No engine or evidence-collector execution. + +Remaining IMPLEMENTATION, separate from missing Root evidence: +1. Complete screenshot byte-magic dimensions, stdlib PNG pixel/rect checks, and indexed Root JPEG-to-lossless-PNG conversion binding to raw hash/converter provenance. PIL absence is not waived. +2. Add prior-frame visual-position/frame-delta evidence and independent interpolation checks; compare rest destinations with typed stored destination/eligibility without re-simulating AI. +3. Bind all request report rows to viewport rectangles/affine and screenshots; current endpoint retains report plus viewport-to-screen transform but does not yet produce every required viewport row rectangle. +4. Finish independent distinct-PID/formal-member/resume-state/load-ACK gates. Fixture assertions do not replace checker proof. +5. Replace hard failure condition-matrix deficits with exact completed check names only after all seven independent gates exist; finish all requested category and infrastructure negatives. Current check intentionally cannot pass whole-unit integrity. +6. Statically review collector lifecycle/failure receipts and fixture category coverage; real constructibility and EV/passenger/queue/goods coverage remain unproved. + +Root native, actual OS for A/B, formal distinct-PID resume, exact-final full regression, independent review, integration/final/ledger: all PENDING. This candidate is blocked on remaining in-scope implementation, not ready for acceptance or a claim that missing Root evidence is the only gap. + +--- +Previous worker status retained verbatim as history (superseded by the repair status above): + # P23-03 worker status: submitted static work, implementation incomplete Base commit: `860136f7099f5e3aee99da5798a627a77f2d8eea`. diff --git a/tests/fixtures/p23-03/diagnostic_stream.gd b/tests/fixtures/p23-03/diagnostic_stream.gd index fb2706a..fd4992e 100644 --- a/tests/fixtures/p23-03/diagnostic_stream.gd +++ b/tests/fixtures/p23-03/diagnostic_stream.gd @@ -3,6 +3,40 @@ extends RefCounted const Codec := preload("res://tests/fixtures/p08-02/mixed_world.gd") var directory: String var manifest: FileAccess +var observer: MotionObserver + +class MotionObserver extends Node: + var view: WorldPreview + var frames: Array = [] + var elapsed := 0.0 + var scaled_elapsed := 0.0 + func _ready() -> void: + RenderingServer.frame_post_draw.connect(after_draw) + func _process(delta: float) -> void: + elapsed += delta + var clock := get_node_or_null("/root/GameClock") + if clock == null or not bool(clock.get("paused")): + scaled_elapsed += delta * maxf(float(clock.get("speed")) if clock != null else 1.0, 0.0) + func after_draw() -> void: + if not is_instance_valid(view): return + var report := view.get_life_observation_report() + if report.is_empty(): return + var sample := {} + for key: String in ["animation_time", "paused", "layer", "zoom", "pan", "viewport", "actor_tracks", "elevator_tracks", "actor_drawings", "cabins"]: + sample[key] = report[key] + if not frames.is_empty() and sample == frames[-1]["display"] and not sample["paused"]: return + # Keep only two draw frames, never whole Runtime copies per frame. + frames.append({"frame": Engine.get_process_frames(), "delta": elapsed, "scaled_delta": scaled_elapsed, "display": sample}) + if frames.size() > 2: frames.pop_front() + elapsed = 0.0 + scaled_elapsed = 0.0 + func _exit_tree() -> void: + if RenderingServer.frame_post_draw.is_connected(after_draw): RenderingServer.frame_post_draw.disconnect(after_draw) + +func watch(view: WorldPreview) -> void: + observer = MotionObserver.new() + observer.view = view + view.add_child(observer) func open(path: String) -> bool: directory = path @@ -33,19 +67,71 @@ func capture(label: String, runtime: Variant, view: WorldPreview) -> bool: var report := view.get_life_observation_report() var state_hash := object(state) var report_hash := object(report) - if state_hash.is_empty() or report_hash.is_empty(): + var audit_hash := object(rest_audit(runtime)) + var motion_hash := object({"frames": observer.frames.duplicate(true) if is_instance_valid(observer) else []}) + var after: Dictionary = runtime.create_snapshot() + var after_hash := object(after) + if state_hash.is_empty() or report_hash.is_empty() or audit_hash.is_empty(): return false manifest.store_line(JSON.stringify({"label": label, "pid": OS.get_process_id(), "ticks_usec": Time.get_ticks_usec(), "state": state_hash, "report": report_hash, + "rest_audit": audit_hash, "motion": motion_hash, "state_after": after_hash, "input_method": "diagnostic", "acceptance": "pending"})) manifest.flush() - return var_to_bytes(state) == var_to_bytes(runtime.create_snapshot()) + return var_to_bytes(state) == var_to_bytes(after) func close() -> void: if manifest != null: manifest.close() manifest = null +# Public selection inputs; Runtime output is only a comparison operand. +func rest_audit(runtime: Variant) -> Dictionary: + var rows: Array = [] + for member: StaffRuntime in runtime.staff_service.get_staff_members(): + var memberships: Array = [] + for raid_id: StringName in runtime.defense_service.get_raid_ids(): + var raid: Dictionary = runtime.defense_service.get_raid(raid_id) + if raid.get(&"combatants", {}).has(member.staff_id): + memberships.append({"raid_id": raid_id, "state": DefenseService.RaidState.keys()[raid[&"state"]], + "side": raid[&"combatants"][member.staff_id].get(&"side", &"")}) + var facilities: Array = [] + for room: FacilityInstance in runtime.facility_service.get_instances(): + var probes: Array = [] + var candidates: Dictionary = {} + var occupied := room.occupied_cells.duplicate() + if occupied.is_empty(): occupied.append(room.origin) + for cell: Vector3i in occupied: + for delta: Vector3i in [Vector3i.ZERO, Vector3i.LEFT, Vector3i.RIGHT]: + var present: bool = runtime.path_service.has_cell(cell + delta) + probes.append({"cell": cell + delta, "has_cell": present}) + if present: candidates[cell + delta] = true + var ordered: Array = candidates.keys() + ordered.sort_custom(func(a: Vector3i, b: Vector3i) -> bool: + if a.z != b.z: return a.z < b.z + if a.y != b.y: return a.y < b.y + return a.x < b.x) + var routes: Array = [] + for cell: Vector3i in ordered: + var path: Array = runtime.path_service.find_path(member.current_cell, cell, + [WorldService.LAYER_FRONT, WorldService.LAYER_BACK], [&"staff"]) + var edges: Array = [] + for index: int in range(1, path.size()): + # Empty reports remain explicit mismatch evidence. + edges.append(runtime.path_service.get_edge_report(path[index - 1], path[index], [&"staff"])) + routes.append({"cell": cell, "path": path, "edges": edges, + "calculate_path_cost": runtime.path_service.calculate_path_cost(path, [&"staff"]) if not path.is_empty() else -1}) + facilities.append({"instance_id": room.instance_id, "definition_id": room.definition_id, + "operation_state": FacilityInstance.OperationState.keys()[room.operation_state], + "origin": room.origin, "occupied_cells": room.occupied_cells.duplicate(), + "probes": probes, "ordered": ordered, "routes": routes}) + rows.append({"staff_id": member.staff_id, "state": StaffRuntime.State.keys()[member.state], + "health": member.health, "current_task_id": member.current_task_id, "current_cell": member.current_cell, + "defense_memberships": memberships, "actor_lock": runtime.event_service.get_actor_locks().has(member.staff_id), + "in_transit": runtime.is_staff_in_transit(member.staff_id), "facilities": facilities, + "comparison_only": runtime.get_staff_rest_report(member.staff_id)}) + return {"staff": rows, "path_service_sha256": FileAccess.get_sha256("res://scripts/world/path_service.gd")} + func record(label: String, value: Dictionary) -> bool: if manifest == null: return false var digest := object(value) diff --git a/tests/fixtures/p23-03/layout_comparison.gd b/tests/fixtures/p23-03/layout_comparison.gd index 0f67a0e..4158841 100644 --- a/tests/fixtures/p23-03/layout_comparison.gd +++ b/tests/fixtures/p23-03/layout_comparison.gd @@ -62,6 +62,7 @@ func run(tree: SceneTree, directory: String, layout: String) -> bool: view.size = Vector2(1440, 810) view.set_runtime(runtime) tree.root.add_child(view) + stream.watch(view) var finished: Dictionary = {} for offset: int in range(WINDOW + 1): if not ok: break @@ -77,20 +78,19 @@ func run(tree: SceneTree, directory: String, layout: String) -> bool: ok = stream.record("arrival-%s" % arrival[&"id"], {&"offset": offset, &"payload": payload, &"result": result.to_dictionary()}) and result.is_success() and ok # Typed per-minute projections retain paths, queues, density and actual # completed services. Whole aggregate snapshots are only sampled sparsely. + var guest_records: Dictionary = runtime.guest_service.create_snapshot() ok = stream.record("tick-%03d" % offset, {&"minute": clock.absolute_minute, - &"guests": runtime.guest_service.create_snapshot(), &"path": runtime.path_service.create_snapshot(), + &"guests": guest_records, &"path": runtime.path_service.create_snapshot(), &"elevators": runtime.elevators.create_snapshot(), &"facilities": runtime.facility_service.create_snapshot()}) and ok - for guest: GuestRuntime in runtime.guest_service.get_guests(): - if guest.current_cell != GOAL or finished.has(guest.guest_id): continue - finished[guest.guest_id] = offset - var leaving: Dictionary = runtime.guest_service.begin_leaving(guest.guest_id, clock.absolute_minute, &"COMPARISON_DESTINATION_REACHED") - var despawn: Dictionary = runtime.guest_service.despawn_guest(guest.guest_id, clock.absolute_minute) - ok = bool(leaving.get(&"ok", false)) and bool(despawn.get(&"ok", false)) and ok + for guest: Dictionary in guest_records[&"guests"]: + if guest[&"current_cell"] != GOAL or finished.has(guest[&"guest_id"]): continue + finished[guest[&"guest_id"]] = offset if offset % 30 == 0: view.queue_redraw() await tree.process_frame await tree.process_frame ok = stream.capture("frame-%03d" % offset, runtime, view) and ok + ok = stream.record("first-arrivals", {&"offsets": finished}) and ok ok = stream.record("final", runtime.create_snapshot()) and ok stream.close() view.free() diff --git a/tests/fixtures/p23-03/observation.gd b/tests/fixtures/p23-03/observation.gd new file mode 100644 index 0000000..b077ab1 --- /dev/null +++ b/tests/fixtures/p23-03/observation.gd @@ -0,0 +1,161 @@ +extends "res://tests/fixtures/p08-03/placement.gd" +## Real Runtime commands; inherited formal codec/save-operation helpers unchanged. +const Stream := preload("res://tests/fixtures/p23-03/diagnostic_stream.gd") +const Comparison := preload("res://tests/fixtures/p23-03/layout_comparison.gd") +var stream: RefCounted +var layout := "A" +var facility_ids: Dictionary = {} + +func setup() -> void: + clock.reset(1, 6, 0) + var state: Variant = tree.root.get_node("GameState") + state.reset() + expect(runtime.initialize(clock, state, 806042).get(&"valid", false), "initialize observation") + clock.set_paused(true) + tree.root.size = Vector2i(1440, 810) + layout = OS.get_environment("P23_LAYOUT") + expect(layout in ["A", "B"], "declared layout A or B") + main = load("res://scenes/main/main.tscn").instantiate() + main.manual_save_path = "user://saves/manual_ui" + if role == "reader": + import_package(directory.path_join("formal"), String(main.manual_save_path)) + tree.root.add_child(main) + runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + stream = Stream.new() + expect(stream.open(directory.path_join(role + "-stream")), "fresh observation stream") + stream.watch(main.world_preview) + await frames() + +func command(id: StringName, payload: Dictionary) -> bool: + var result: CommandResult = runtime.submit_command(id, payload) + expect(result.is_success(), String(id) + ": " + String(result.code)) + expect(stream.record("command", {&"command": id, &"payload": payload, + &"result": result.to_dictionary(), &"minute": clock.absolute_minute}), "record public command") + return result.is_success() + +func advance(minutes: int) -> void: + for unused: int in range(minutes): + clock.advance_minutes(1) + await frames() + await observe_progress() + if clock.absolute_minute % 30 == 0: + expect(stream.capture("minute-%d" % clock.absolute_minute, runtime, main.world_preview), "sparse minute observation") + +func observe_progress() -> void: + pass + +func prepare() -> void: + # Both layouts receive the same definition-derived diagnostic construction budget. + # Cafeteria/kitchen move together to x=6, leaving x=4..5 for the declared B EV. + var builds: Array = [[&"reception", Vector3i(-8, 0, 0)], + [&"cafeteria", Vector3i(6, 0, 0)], [&"warehouse", Vector3i(-8, 0, 1)], + [&"kitchen", Vector3i(6, 0, 1)], [&"staff_door", Vector3i(0, 0, 1)], + [&"standard_room", Vector3i(-8, 2, 0)], [&"guest_stairs", Vector3i(-4, 1, 0)], + [&"guest_stairs", Vector3i(-4, 2, 0)], [&"guest_corridor", Vector3i(-5, 2, 0)], + [&"guest_corridor", Vector3i(-6, 2, 0)], [&"guest_elevator", Vector3i.ZERO], + [&"guest_corridor", Vector3i(2, 1, 0)], [&"guest_corridor", Vector3i(3, 1, 0)], + [&"staff_waiting_room", Vector3i(-4, 0, 1)]] + var improvement: Dictionary = Comparison.new().declaration()[&"improvement"] + var budget_builds := builds.duplicate(true) + budget_builds.append([improvement[&"definition_id"], improvement[&"origin"]]) + var needed := {&"gold": 0, &"materials": 0} + var costs: Array = [] + for row: Array in budget_builds: + var definition: FacilityDefinition = runtime.definition_registry.get_facility(row[0]) + costs.append({&"definition": row[0], &"cost": definition.construction_cost.duplicate(true)}) + for key: StringName in needed: + needed[key] += int(definition.construction_cost.get(key, 0)) + var state: Variant = tree.root.get_node("GameState") + var deltas := {} + for key: StringName in needed: + if needed[key] > state.get_resource(key): + deltas[key] = needed[key] - state.get_resource(key) + budget = {"setup": "diagnostic", "costs": costs, "needed": needed, + "initial": state.resources.duplicate(true), "deltas": deltas} + if not deltas.is_empty(): command(&"change_resources", {&"deltas": deltas}) + expect(stream.record("budget", budget), "common diagnostic budget") + if layout == "B": builds.append([improvement[&"definition_id"], improvement[&"origin"]]) + # Preserve the supply scenario's staged support/construction sequence. + for index: int in range(builds.size()): + if index == 5: await advance(180) + if index == 7: await advance(60) + if index == 8: await advance(10) + command(&"build_facility", {&"definition_id": builds[index][0], &"origin": builds[index][1]}) + var transport_minutes := 0 + for id: StringName in [&"guest_elevator", &"guest_corridor"]: + transport_minutes = maxi(transport_minutes, runtime.definition_registry.get_facility(id).build_minutes) + await advance(maxi(10, transport_minutes)) + for facility: FacilityInstance in runtime.facility_service.get_instances(): + facility_ids[facility.definition_id] = facility.instance_id + command(&"set_facility_conditions", {&"facility_id": facility_ids[&"cafeteria"], + &"conditions": {&"related_facility_id": facility_ids[&"kitchen"]}}) + for assignment: Array in [[&"staff_akane", &"reception"], [&"staff_gina", &"warehouse"], + [&"staff_chika", &"kitchen"], [&"staff_iris", &"cafeteria"], [&"staff_juno", &"cafeteria"]]: + command(&"hire_staff_candidate", {&"candidate_id": assignment[0]}) + command(&"set_staff_work_assignment", {&"staff_id": assignment[0], + &"facility_id": facility_ids[assignment[1]], &"template_id": &"DAY"}) + command(&"purchase_supplies", {&"warehouse_id": facility_ids[&"warehouse"], + &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 40}) + command(&"set_kitchen_production", {&"facility_id": facility_ids[&"kitchen"], + &"enabled": true, &"target_stock": 8}) + # The OS observation uses the already declared transport cohort, plus + # ordinary public arrivals whose service choice remains Runtime-owned. + # These commands are recorded and identical across A/B; no actor is moved, + # despawned, or inserted into a room/queue by the fixture. + var demand: Dictionary = Comparison.new().declaration() + expect(stream.record("observation-demand", {"cohort": demand[&"arrivals"], + "schedule": "all observation actors at the recorded current minute; offsets apply only to native layout comparison", + "minute": clock.absolute_minute}), "declared observation demand") + for arrival: Dictionary in demand[&"arrivals"]: + command(&"spawn_guest", {&"guest_id": StringName("os-" + String(arrival[&"id"])), + &"guest_type_id": &"traveler", &"gold": 100, + &"options": {&"current_cell": Comparison.START, &"state": GuestRuntime.State.MOVING, + &"state_entered_at": clock.absolute_minute - 1, &"planned_exit_at": clock.absolute_minute + 2000, + &"path_handle": {&"target_cell": Comparison.GOAL}}}) + command(&"spawn_guest", {&"guest_id": StringName("service-" + String(arrival[&"id"])), + &"guest_type_id": &"traveler", &"gold": 100}) + await advance(120) + +func layer_observations(prefix: String) -> void: + await button(main.front_button) + expect(stream.capture(prefix + "-FRONT", runtime, main.world_preview), "FRONT state/report") + await button(main.back_button) + expect(stream.capture(prefix + "-BACK", runtime, main.world_preview), "BACK state/report") + +func writer() -> void: + await prepare() + await layer_observations("writer") + capture("presave") + await button(main.find_child("ManualSaveButton", true, false)) + expect(main.build_status_label.text == "手動セーブを保存しました。", "normal save") + capture("checkpoint") + export_package(String(main.manual_save_path), "formal") + var formal := inspect_formal() + var presave := FileAccess.get_file_as_bytes(directory.path_join("writer-presave.var")) + expect(presave == var_to_bytes(formal), "whole presave formal model") + expect(not operation_results.is_empty(), "save result recorded") + if not operation_results.is_empty(): + verify_operation(formal, operation_results.back(), &"SAVE_CHECKPOINT_SUCCEEDED") + var members := {} + for member: String in MEMBERS: + members[member] = FileAccess.get_sha256(directory.path_join("formal/" + member)) + json_file("save.json", {"pid": OS.get_process_id(), "members": members}) + +func reader(distinct: bool) -> void: + var saved: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(directory.path_join("save.json"))) + expect(distinct and saved.pid != OS.get_process_id(), "distinct actual reader PID") + await load_menu(main.find_child("SaveLoadMenu", true, false)) + await frames() + expect(main.build_status_label.text == "手動セーブを読み込みました。", "normal resume") + capture("checkpoint") + var formal := inspect_formal() + expect(FileAccess.get_file_as_bytes(directory.path_join("writer-formal-model.var")) == var_to_bytes(formal), "whole formal resume model") + for member: String in MEMBERS: + expect(FileAccess.get_sha256(directory.path_join("formal/" + member)) == saved.members[member], "formal member " + member) + expect(not operation_results.is_empty(), "load result recorded") + if not operation_results.is_empty(): + verify_operation(formal, operation_results.back(), &"SAVE_RECOVERY_LOADED") + await layer_observations("reader") + +func finish_observations() -> void: + stream.close() diff --git a/tests/fixtures/p23-03/passive_observation.gd b/tests/fixtures/p23-03/passive_observation.gd new file mode 100644 index 0000000..ea1b577 --- /dev/null +++ b/tests/fixtures/p23-03/passive_observation.gd @@ -0,0 +1,254 @@ +extends "res://tests/fixtures/p23-03/observation.gd" +## Passive endpoint implementation. All gameplay/input is performed by Root CUA. +## Shared parent contains the handler endpoints, but every such endpoint below +## is overridden; this class never emits UI signals or invokes product handlers. +var receipts: Array = [] +var terminated := false +var terminal_record: Dictionary = {} +var terminal_cause_recorded := false +var reveal_rows: Array = [] +var all_requests: Array = [] +var input_observer: Node +var last_dispatch: Dictionary = {} +var observed_categories: Dictionary = {} + +func observe_progress() -> void: + if role != "writer" or terminated: return + var wanted: Dictionary = {} + var report: Dictionary = main.world_preview.get_life_observation_report() + for room: Dictionary in report.get(&"rooms", {}).values(): + if int(room.get(&"count", 0)) > 0: wanted["room-users"] = true + for badge: Dictionary in report.get(&"landing_badges", []): + if int(badge.get(&"overflow", 0)) > 0: wanted["queue-overflow"] = true + for cabin: Dictionary in report.get(&"cabins", []): + if not cabin[&"passengers"].is_empty(): wanted["EV-passengers"] = true + var elevators: Dictionary = runtime.elevators.get_report() + for shaft: Dictionary in elevators.get(&"shafts", []): + if not shaft.get(&"boarded", []).is_empty(): wanted["EV-passengers"] = true + for trip: Dictionary in elevators.get(&"trips", []): + if trip.get(&"state", &"") == &"QUEUED": wanted["queue-present"] = true + for member: StaffRuntime in runtime.staff_service.get_staff_members(): + if not member.carried_inventory_id.is_empty(): wanted["carried-goods"] = true + if not report.get(&"resting_staff", {}).is_empty(): wanted["rest-position"] = true + for category: String in wanted: + if observed_categories.has(category): continue + observed_categories[category] = true + await layer_observations("actual-" + category) + if category == "EV-passengers": await motion_observations() + +func motion_observations() -> void: + await button(main.front_button) + await button(main.pause_button) + expect(not clock.paused, "ordinary resume for motion observation") + var minute: int = clock.absolute_minute + while clock.absolute_minute == minute and not terminated: + await tree.process_frame + await frames() + expect(stream.capture("active-motion", runtime, main.world_preview), "active interpolation history") + await button(main.pause_button) + expect(clock.paused, "ordinary pause for stable observation") + await frames() + expect(stream.capture("paused-motion", runtime, main.world_preview), "paused interpolation history") + +class InputObserver extends Node: + var events: Array = [] + var endpoint: RefCounted = null + func _input(event: InputEvent) -> void: + if event is InputEventMouseButton or event is InputEventKey or event is InputEventMouseMotion: + var row := {"ticks_usec": Time.get_ticks_usec(), "text": event.as_text(), "pressed": event.is_pressed()} + if event is InputEventMouseButton: + row["button"] = event.button_index + row["position"] = [event.position.x, event.position.y] + row["ctrl"] = event.ctrl_pressed + if endpoint != null and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: + row["dispatch"] = endpoint.dispatch_position(event.position) + events.append(row) + +func expect(ok: bool, message: String) -> void: + if terminated and ok: + return + if terminated and terminal_cause_recorded: + if not ok: + terminal_record["suppressed_after_terminal"] += 1 + json_file(role + "-terminal.json", terminal_record) + return + if terminated and not ok: + terminal_cause_recorded = true + super.expect(ok, message) + +func terminate(stem: String, reason: String) -> void: + terminated = true + terminal_record = {"pid": OS.get_process_id(), "role": role, "sequence": serial, + "stem": stem, "reason": reason, "suppressed_after_terminal": 0} + json_file(role + "-terminal.json", terminal_record) + expect(false, reason) + tree.quit(1) + +func request(action: Dictionary) -> void: + if terminated: + return + serial += 1 + var stem := "%s-request-%04d" % [role, serial] + var folder := directory.path_join(stem) + DirAccess.make_dir_recursive_absolute(folder) + var window: Window = main.get_window() + var view: WorldPreview = main.world_preview + action["report"] = Codec.new().typed(view.get_life_observation_report()) + action["viewport_to_screen"] = affine(view.get_screen_transform() * view.get_global_transform_with_canvas().affine_inverse()) + action["local_to_viewport"] = affine(view.get_global_transform_with_canvas()) + action["local_to_screen"] = affine(view.get_screen_transform()) + action["rectangles"] = Codec.new().typed(draw_rectangles(view.get_life_observation_report(), view)) + action["requested_ticks_usec"] = Time.get_ticks_usec() + action["requested_unix_ns"] = int(Time.get_unix_time_from_system() * 1000000000.0) + action["screenshot_receipt_schema"] = {"screenshots": [{"path": "evidence-root-relative raw image path", "raw_sha256": "original byte SHA256", "tool_call_id": "actual CUA screenshot call ID"}]} + expect(stream.capture(stem, runtime, view), "request state/report") + action.merge({"sequence": serial, "target_pid": OS.get_process_id(), "role": role, + "state": "waiting_for_root_cua", "window_title": window.title, + "window_position": [window.position.x, window.position.y], + "window_size": [window.size.x, window.size.y], "screen_scale": DisplayServer.screen_get_scale(window.current_screen), + "content_scale_factor": window.content_scale_factor, "setup": "diagnostic"}) + json_file(stem + "/request.json", action) + var path := folder.path_join("ack.json") + var first_event: int = input_observer.events.size() + var deadline := Time.get_ticks_msec() + 300000 + print("P23_ROOT_CUA_WAIT " + folder.path_join("request.json")) + while not FileAccess.file_exists(path) and Time.get_ticks_msec() < deadline: + await tree.process_frame + if not FileAccess.file_exists(path): + terminate(stem, "Root CUA acknowledgement timeout") + return + var ack: Variant = JSON.parse_string(FileAccess.get_file_as_string(path)) + if not ack is Dictionary or ack.get("target_pid") != OS.get_process_id() or ack.get("sequence") != serial or ack.get("request_sha256") != FileAccess.get_sha256(folder.path_join("request.json")): + terminate(stem, "Root CUA acknowledgement identity/hash") + return + await frames() + for event: Dictionary in input_observer.events.slice(first_event): + if event.has("dispatch"): + last_dispatch = event["dispatch"] + json_file(stem + "/observed.json", {"pid": OS.get_process_id(), "sequence": serial, + "ack_observed_ticks_usec": Time.get_ticks_usec(), "request_sha256": FileAccess.get_sha256(folder.path_join("request.json")), + "ack_observed_unix_ns": int(Time.get_unix_time_from_system() * 1000000000.0), + "observer_endpoint": "null_modal_receipts_only" if input_observer.endpoint == null else "passive_read_only_dispatch", + "events": input_observer.events.slice(first_event), "status": main.build_status_label.text}) + receipts.append("os-pair/" + layout + "/" + stem + "/ack.json") + all_requests.append("os-pair/" + layout + "/" + stem + "/ack.json") + +func annotate(row: Dictionary) -> void: + var view: WorldPreview = main.world_preview + row["rectangles"] = Codec.new().typed(draw_rectangles(view.get_life_observation_report(), view)) + row["viewport_to_screen"] = affine(view.get_screen_transform() * view.get_global_transform_with_canvas().affine_inverse()) + row["reveal"] = reveal_rows.duplicate(true) + reveal_rows.clear() + row["input_method"] = "native_os" + row["requests"] = receipts.duplicate() + receipts.clear() + +# Rectangles describe existing draw commands; no second renderer is invoked. +func draw_rectangles(report: Dictionary, view: WorldPreview) -> Dictionary: + var rows: Array = [] + for id: Variant in report.get(&"rooms", {}): + var room: Dictionary = report[&"rooms"][id] + if room.has(&"rect"): + rows.append({"category": "room", "id": id, "rect": room[&"rect"], "style_source": "scripts/ui/pixel_tower_art.gd"}) + for index: int in range(report.get(&"landing_badges", []).size()): + var badge: Dictionary = report[&"landing_badges"][index] + rows.append(badge_rectangle("queue", str(index), badge[&"at"], badge[&"text"])) + for cabin: Dictionary in report.get(&"cabins", []): + rows.append({"category": "EV", "id": cabin[&"shaft_id"], "rect": cabin[&"rect"], "style_source": "scripts/ui/pixel_tower_art.gd"}) + for actor: Dictionary in cabin[&"passengers"]: + rows.append(actor_rectangle(actor)) + for actor: Dictionary in report.get(&"actor_drawings", []): + rows.append(actor_rectangle(actor)) + if actor[&"staff"] and report[&"resting_staff"].has(StringName(String(actor[&"key"]).trim_prefix("staff/"))): + var rest := actor_rectangle(actor) + rest["category"] = "rest" + rows.append(rest) + for index: int in range(report.get(&"badges", []).size()): + var badge: Dictionary = report[&"badges"][index] + rows.append(badge_rectangle("goods" if badge[&"kind"] == &"supply" else "business", str(index), badge[&"at"], badge[&"text"])) + var transform := view.get_global_transform_with_canvas() + for row: Dictionary in rows: + row["viewport_rect"] = transform * (row["rect"] as Rect2) + row["screen_rect"] = view.get_screen_transform() * (row["rect"] as Rect2) + var source: String = FileAccess.get_file_as_string("res://" + row["style_source"]) + var regex := RegEx.new() + regex.compile('Color\\("([0-9a-fA-F]{6})"') + var colors: Array = [] + for found: RegExMatch in regex.search_all(source): + if not colors.has(found.get_string(1)): colors.append(found.get_string(1)) + row["style_colors"] = colors + return {"rows": rows} + +func badge_rectangle(category: String, id: String, at: Vector2, text: String) -> Dictionary: + var width := ThemeDB.fallback_font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, 10).x + 6 + return {"category": category, "id": id, "rect": Rect2(at, Vector2(width, 14)), + "text": text, "font_width": width - 6, "style_source": "scenes/main/world_preview.gd"} + +func actor_rectangle(actor: Dictionary) -> Dictionary: + return {"category": "staff" if actor[&"staff"] else "guest", "id": actor[&"key"], + "rect": Rect2(actor[&"at"], Vector2(16, 18) * float(actor[&"scale"])), "style_source": "scripts/ui/pixel_people.gd"} + +func point(node: Control) -> Dictionary: + var viewport: Vector2 = node.get_global_transform_with_canvas() * (node.size * 0.5) + var screen: Vector2 = node.get_screen_transform() * (node.size * 0.5) + return {"node": String(node.get_path()), "viewport": [viewport.x, viewport.y], "screen": [screen.x, screen.y], "window_id": node.get_window().get_window_id(), + "viewport_to_screen": affine(node.get_screen_transform() * node.get_global_transform_with_canvas().affine_inverse())} + +func setup() -> void: + await super.setup() + input_method = "native_os" + expect(DisplayServer.get_name() != "headless", "real OS display") + tree.root.title = "The Tree P23-03 " + layout + " " + role + " PID " + str(OS.get_process_id()) + input_observer = InputObserver.new() + # No handler dispatch: this observer only records incoming OS events. + tree.root.add_child(input_observer) + json_file(role + "-profile.json", {"pid": OS.get_process_id(), + "user_data_dir": OS.get_user_data_dir(), "engine": Engine.get_version_info(), + "display": DisplayServer.get_name()}) + +func button(node: BaseButton) -> void: + expect(node != null and not node.disabled, "reachable ordinary button") + if node == null or node.disabled: return + await request({"action": "click", "point": point(node), "button_text": str(node.get("text"))}) + +func load_menu(menu: MenuButton) -> void: + var action := point(menu) + action.merge({"action": "load_menu", "item_id": 0, "instructions": "Open this normal menu and choose manual load (item 0)."}) + await request(action) + +func screenshot(label: String) -> void: + await frames() + await request({"action": "screenshot", "id": label, "layout": layout}) + +func layer_observations(prefix: String) -> void: + if role == "writer" and prefix == "writer": await screenshot("calibration") + await button(main.front_button) + await screenshot(prefix + "-FRONT") + await button(main.back_button) + await screenshot(prefix + "-BACK") + if role == "writer" and prefix == "writer": await ordinary_camera() + +func ordinary_camera() -> void: + var view: WorldPreview = main.world_preview + var at := point(view) + expect(not view.pan_mode, "ordinary zoom starts with move mode off") + var zoom: float = view.zoom_level + await request({"action": "wheel", "point": at, "direction": "down", + "ctrl": false, "move_mode": false}) + expect(view.zoom_level != zoom, "ordinary wheel changes zoom") + await screenshot("ordinary-wheel-zoom") + await button(main.pan_button) + await screenshot("pan-mode-button") + expect(view.pan_mode, "PanModeButton enables move mode") + zoom = view.zoom_level + var pan: Vector2 = view.pan_offset + await request({"action": "wheel", "point": point(view), "direction": "up", + "ctrl": false, "move_mode": true}) + expect(view.pan_offset != pan and view.zoom_level == zoom, "ordinary move-mode wheel pans") + await screenshot("move-mode-wheel-pan") + +func finish_observations() -> void: + json_file(role + "-requests.json", {"requests": all_requests, "pid": OS.get_process_id()}) + if is_instance_valid(input_observer): input_observer.queue_free() + super.finish_observations() + diff --git a/tests/fixtures/p23-03/passive_os_recorder.gd b/tests/fixtures/p23-03/passive_os_recorder.gd new file mode 100644 index 0000000..e2b6544 --- /dev/null +++ b/tests/fixtures/p23-03/passive_os_recorder.gd @@ -0,0 +1,8 @@ +extends SceneTree +const Passive := preload("res://tests/fixtures/p23-03/passive_observation.gd") +func _initialize() -> void: + call_deferred("run") +func run() -> void: + var result: Dictionary = await Passive.new().run(self, OS.get_environment("P23_OBSERVATION_ROOT"), OS.get_environment("P23_OBSERVATION_ROLE")) + print("P23 passive OS observation: %d checks, %d failures" % [result.checks, result.failures]) + quit(0 if result.failures == 0 else 1) diff --git a/tests/ui/test_p23_life_observation.gd b/tests/ui/test_p23_life_observation.gd index 5b023b6..fb9f077 100644 --- a/tests/ui/test_p23_life_observation.gd +++ b/tests/ui/test_p23_life_observation.gd @@ -15,6 +15,14 @@ func expect(value: bool, message: String) -> void: push_error("P23 life observation: " + message) func run() -> void: + var layout := OS.get_environment("P23_LAYOUT") + var output_directory := OS.get_environment("P23_OUTPUT") + if layout in ["A", "B"] and not output_directory.is_empty(): + var comparison := preload("res://tests/fixtures/p23-03/layout_comparison.gd").new() + expect(await comparison.run(self, output_directory, layout), "native layout " + layout) + print("P23 life observation: %d checks, %d failures" % [checks, failures]) + quit(0 if failures == 0 else 1) + return var clock: Variant = root.get_node("GameClock") clock.set_paused(true) # Isolated real typed components test state labels, without claiming gameplay. diff --git a/tools/check_p23_life_observation_evidence.py b/tools/check_p23_life_observation_evidence.py index 6c1e697..048ead2 100644 --- a/tools/check_p23_life_observation_evidence.py +++ b/tools/check_p23_life_observation_evidence.py @@ -10,9 +10,12 @@ import hashlib import importlib.util import json import math +import os from pathlib import Path import re import sys +import struct +import zlib sys.dont_write_bytecode = True ROOT = Path(__file__).resolve().parent.parent @@ -24,15 +27,13 @@ CONDITIONS = ( 'supplies_and_carried_goods', 'same_demand_route_improvement', 'normal_os_and_distinct_pid_resume', ) -BLOCKERS = { - CONDITIONS[0]: 'Service/rescue occupancy, reason fields and room rectangles checked; rest route selection and badge pixels unfinished.', - CONDITIONS[1]: 'Queue lane allocation, overflow and positions checked; screenshot pixel provenance unfinished.', - CONDITIONS[2]: 'Shaft/trip and cabin geometry checked; prior-frame interpolation history and passenger geometry unfinished.', - CONDITIONS[3]: 'Typed staff/task projection implemented; motion/rest/rescue/cleaning drawing recomputation unfinished.', - CONDITIONS[4]: 'Carried inventory ID and logistics report checked; supply badge pixels unfinished.', - CONDITIONS[5]: 'Public-command A/B fixture and typed arrival metric added; native execution, definition pins and full command/path provenance checks pending.', - CONDITIONS[6]: 'P23 passive request/ACK collector and independent CUA/formal-resume gates unfinished; Root evidence missing.', -} +CATEGORIES = ('room', 'queue', 'EV', 'staff', 'goods', 'business') +CONDITION_CHECKS = dict(zip(CONDITIONS, ('room_users', 'queue_overflow', 'elevator_passengers', + 'staff_jobs', 'carried_goods', 'route_improvement', 'distinct_pid_resume'))) +# Required names, not verdicts. BLOCKERS is computed from actual execution below. +REQUIRED_CHECKS = {condition: {category: {'semantic_' + category, 'pixels_' + category, + CONDITION_CHECKS[condition], 'os_guards', 'resume_recalculation'} + for category in CATEGORIES} for condition in CONDITIONS} def module(name, path): @@ -74,6 +75,199 @@ def sha(path): return hashlib.sha256(Path(path).read_bytes()).hexdigest() +def png_pixels(data): + """Decode noninterlaced 8-bit RGB/RGBA PNG, validating the whole stream.""" + require(data[:8] == b'\x89PNG\r\n\x1a\n', 'PNG signature') + pos, header, payload, ended, idat_closed = 8, None, bytearray(), False, False + while pos < len(data): + require(pos + 12 <= len(data), 'PNG truncated chunk') + size, kind = struct.unpack('>I4s', data[pos:pos + 8]) + require(pos + size + 12 <= len(data), 'PNG truncated payload') + body = data[pos + 8:pos + 8 + size] + crc = struct.unpack('>I', data[pos + 8 + size:pos + 12 + size])[0] + require(zlib.crc32(kind + body) == crc, 'PNG CRC mismatch') + pos += size + 12 + require(header is not None or kind == b'IHDR', 'PNG IHDR must be first') + if kind == b'IHDR': + require(header is None and size == 13, 'PNG duplicate/invalid IHDR') + w, h, depth, color, compression, filtering, interlace = struct.unpack('>IIBBBBB', body) + require(w > 0 and h > 0 and depth == 8 and color in (2, 6) + and (compression, filtering, interlace) == (0, 0, 0), 'PNG unsupported IHDR') + header = (w, h, 3 if color == 2 else 4) + elif kind == b'IDAT': + require(not idat_closed, 'PNG nonconsecutive IDAT') + payload.extend(body) + elif kind == b'IEND': + require(size == 0 and payload and pos == len(data), 'PNG invalid IEND') + ended = True + break + else: + require(kind[0] & 32 or kind == b'PLTE', 'PNG unknown critical chunk') + if payload: + idat_closed = True + require(ended and header is not None, 'PNG missing IEND') + w, h, channels = header + stride = w * channels + expected = h * (stride + 1) + try: + decoder = zlib.decompressobj() + raw = decoder.decompress(bytes(payload), expected + 1) + require(decoder.eof and not decoder.unused_data and not decoder.unconsumed_tail + and len(raw) == expected, 'PNG zlib length/end mismatch') + except zlib.error as exc: + raise ValueError('PNG invalid zlib') from exc + rows, prior = [], bytearray(stride) + for y in range(h): + start = y * (stride + 1) + mode = raw[start] + require(mode in range(5), 'PNG unknown filter') + row = bytearray(raw[start + 1:start + 1 + stride]) + for x in range(stride): + left = row[x - channels] if x >= channels else 0 + up = prior[x] + corner = prior[x - channels] if x >= channels else 0 + if mode == 1: + predictor = left + elif mode == 2: + predictor = up + elif mode == 3: + predictor = (left + up) // 2 + elif mode == 4: + p = left + up - corner + distances = (abs(p - left), abs(p - up), abs(p - corner)) + predictor = (left, up, corner)[distances.index(min(distances))] + else: + predictor = 0 + row[x] = (row[x] + predictor) & 255 + rows.append([tuple(row[x:x + channels]) for x in range(0, stride, channels)]) + prior = row + return w, h, rows + + +def image_kind(data): + if data.startswith(b'\x89PNG\r\n\x1a\n'): + w, h, _ = png_pixels(data) + return 'png', w, h + if data.startswith(b'\xff\xd8'): + helper = module('p23_p08_jpeg', ROOT / 'tools/check_p08_normal_placement_evidence.py') + w, h = helper.jpeg_dimensions(data) + return 'jpeg', w, h + raise ValueError('unknown screenshot byte magic') + + +def inspect_conversion(rec, cua_receipt, converter_sha256=None): + """CUA receipt and converter pin are independent of conversion assertions.""" + raw, png = Path(rec['raw_path']), Path(rec['png_path']) + require(raw.resolve() != png.resolve() and not raw.is_symlink() and not png.is_symlink(), + 'conversion must preserve a separate raw file') + require(rec['raw_sha256'] == sha(raw) == cua_receipt['raw_sha256'] + and rec['tool_call_id'] == cua_receipt['tool_call_id'] + and bool(rec['tool_call_id']), 'CUA raw hash/call mismatch') + require(rec['raw_kind'] == 'jpeg', 'conversion raw kind') + kind, w, h = image_kind(raw.read_bytes()) + require(kind == 'jpeg', 'conversion requires JPEG magic') + require(rec['converter'] == '/usr/bin/sips', 'conversion executable') + pin = converter_sha256 if converter_sha256 is not None else sha('/usr/bin/sips') + require(rec['converter_sha256'] == pin, 'converter SHA mismatch') + require(rec['argv'] == ['/usr/bin/sips', '-s', 'format', 'png', str(raw), '--out', str(png)], + 'converter argv mismatch') + require(type(rec['exit_code']) is int and rec['exit_code'] == 0, 'converter exit code') + for key in ('stdout_sha256', 'stderr_sha256'): + require(isinstance(rec[key], str) and re.fullmatch('[0-9a-f]{64}', rec[key]), + 'invalid converter output digest') + require(sha(png) == rec['png_sha256'], 'converted PNG SHA mismatch') + pw, ph, rows = png_pixels(png.read_bytes()) + require((w, h) == (pw, ph) == (rec['width'], rec['height']), 'conversion dimensions mismatch') + return pw, ph, rows + + +def screenshot_pixels(path, conversion=None, cua_receipt=None, converter_sha256=None): + data = Path(path).read_bytes() + kind, _, _ = image_kind(data) + if kind == 'jpeg': + require(conversion is not None, 'JPEG screenshot requires conversion record') + require(Path(conversion['raw_path']).resolve() == Path(path).resolve(), 'conversion raw path mismatch') + require(cua_receipt is not None, 'missing CUA receipt') + return inspect_conversion(conversion, cua_receipt, converter_sha256) + return png_pixels(data) + + +def affine_point(matrix, point): + require(len(matrix) == 6 and all(type(v) in (int, float) and math.isfinite(v) for v in matrix), 'invalid affine') + a, b, c, d, x, y = matrix + require(a*d-b*c != 0, 'singular affine') + return [a*point[0]+c*point[1]+x, b*point[0]+d*point[1]+y] + + +def affine_rect(matrix, rect): + x, y, w, h = rect + points = [affine_point(matrix, p) for p in ([x,y], [x+w,y], [x,y+h], [x+w,y+h])] + lo = [min(p[i] for p in points) for i in (0, 1)] + return lo + [max(p[i] for p in points)-lo[i] for i in (0, 1)] + + +def compare_rectangles(report, audit, local_to_viewport, viewport_to_screen, local_to_screen, image, root=ROOT): + """All draw rows, full affine composition, and exact existing style colors. + Font advance is recorded by the pinned engine/font, not approximated using + a new text width threshold. No image similarity threshold is introduced. + """ + for point in ([0, 0], [1, 0], [0, 1]): + close(affine_point(viewport_to_screen, affine_point(local_to_viewport, point)), + affine_point(local_to_screen, point), 'viewport/screen composition') + rows = audit['rows'] + expected = [] + tower, people, world = 'scripts/ui/pixel_tower_art.gd', 'scripts/ui/pixel_people.gd', 'scenes/main/world_preview.gd' + def actor(a, category=None): + expected.append((category or ('staff' if a['staff'] else 'guest'), a['key'], + a['at'] + [16*a['scale'], 18*a['scale']], people, None)) + def badge(category, identifier, at, text): + expected.append((category, identifier, at + [None, 14], world, text)) + for fid, room in report['rooms'].items(): + if 'rect' in room: + expected.append(('room', fid, room['rect'], tower, None)) + for i, b in enumerate(report['landing_badges']): + badge('queue', str(i), b['at'], b['text']) + for cabin in report['cabins']: + expected.append(('EV', cabin['shaft_id'], cabin['rect'], tower, None)) + for a in cabin['passengers']: + actor(a) + for a in report['actor_drawings']: + actor(a) + if a['staff'] and a['key'].removeprefix('staff/') in report['resting_staff']: + actor(a, 'rest') + for i, b in enumerate(report['badges']): + badge('goods' if b['kind'] == 'supply' else 'business', str(i), b['at'], b['text']) + require(len(rows) == len(expected), 'rectangle coverage') + width, height, pixels = image + checked = [] + for row, (category, identifier, rect, source, text) in zip(rows, expected): + require((row['category'], row['id'], row['style_source']) == (category, identifier, source), 'rectangle row identity') + if text is not None: + require(row['text'] == text and type(row['font_width']) in (int, float) and row['font_width'] > 0, 'badge font/text') + rect[2] = row['font_width'] + 6 + close(row['rect'], rect, 'local drawing rectangle') + viewport = affine_rect(local_to_viewport, rect) + screen = affine_rect(viewport_to_screen, viewport) + close(row['viewport_rect'], viewport, 'viewport rectangle') + close(row['screen_rect'], screen, 'screen rectangle') + colors = list(dict.fromkeys(re.findall(r'Color\("([0-9a-fA-F]{6})"', (root/source).read_text()))) + require(row['style_colors'] == colors and colors, 'drawing style palette') + x, y, w, h = screen + require(w > 0 and h > 0 and x >= 0 and y >= 0 and x+w <= width and y+h <= height, + 'drawing rectangle outside screenshot') + # Badge ink is distinct from its translucent backing. Actor ink/skin + # and facility colors are taken verbatim from their pinned draw source. + palette = {'fff2ce'} if text is not None else set(colors) + palette -= {'303c40', 'c6d3b6', 'd5dec4'} # shadow / sky decorations + rgb = {tuple(bytes.fromhex(c)) for c in palette} + found = any(tuple(pixels[py][px][:3]) in rgb + for py in range(math.ceil(y), math.floor(y+h)) + for px in range(math.ceil(x), math.floor(x+w))) + require(found, 'missing drawing pixels: ' + category + '/' + str(identifier)) + checked.append(category + '/' + str(identifier)) + return checked + + def enum_names(root, relative, name): source = (root / relative).read_text() match = re.search(r'\benum\s+' + re.escape(name) + r'\s*\{([^}]+)\}', source) @@ -322,6 +516,117 @@ def comparison_records(directory): return records +def room_slots(rect, pixel, definition='staff_waiting_room'): + x, y, w, h = rect + step = 20 if definition in ('cafeteria', 'tavern') else 16 + columns = max(int(w / pixel / step), 1) + rows = max(int((h / pixel - (30 if definition == 'chapel' else 10)) / 20), 1) + if definition == 'chapel': + columns = min(columns, 4) + anchors = [[x + (col + .5) * w / columns, y + h - (4 + row * 20) * pixel] + for row in range(rows) for col in range(columns)] + return anchors[:8] if definition == 'chapel' else anchors + + +def compare_rest(state, report, audit, root=ROOT): + """Recompute selection, not shortest paths. Shortest-path authority is the + exact pinned PathService source producing the recorded public API paths. + Edge reports must be nonempty, including for temporarily closed cells. + """ + require(audit['path_service_sha256'] == sha(root / 'scripts/world/path_service.gd'), + 'rest PathService source pin') + runtime = state['systems']['runtime'] + members = runtime['staff']['staff'] + facilities = runtime['facilities']['instances'] + require([r['staff_id'] for r in audit['staff']] == [r['staff_id'] for r in members], + 'rest staff order/coverage') + passable = {tuple(r['cell']) for r in runtime['path']['cell_traffic']} + resting = {} + selections = {} + for member, row in zip(members, audit['staff']): + sid = member['staff_id'] + for key in ('state', 'health', 'current_task_id', 'current_cell'): + close(row[key], member[key], 'rest input/' + key) + memberships = [dict(raid_id=raid['raid_id'], state=raid['state'], side=c['side']) + for raid in runtime['defense']['raids'] for c in raid['combatants'] + if c['actor_id'] == sid] + close(row['defense_memberships'], memberships, 'rest defense membership') + locked = sid in runtime['events']['actor_locks'] + transit = any(t['actor_id'] == 'staff:' + sid for t in runtime['elevators']['trips']) + close(row['actor_lock'], locked, 'rest actor lock') + close(row['in_transit'], transit, 'rest transit') + defense = any(r['state'] not in ('DEFENDED', 'BREACHED', 'DEFENDERS_RETREATED', 'CANCELLED') + and r['side'] == 'DEFENDER' for r in memberships) + eligible = member['state'] == 'OFF_DUTY' and member['health'] > 0 and not member['current_task_id'] and not locked and not defense + selected = dict(reachable=False, resting=False, label='休憩先なし(稼働中の待機室・宿舎と経路を確認)') if eligible else {} + best_cost = 2147483647 + require([f['instance_id'] for f in row['facilities']] == [f['instance_id'] for f in facilities], + 'rest facility order/coverage') + for facility, observed in zip(facilities, row['facilities']): + for key in ('instance_id', 'definition_id', 'origin', 'occupied_cells'): + close(observed[key], facility[key], 'rest facility/' + key) + operation = 'SUSPENDED' if facility['suspension_reason'] else ('OPERATING' if facility['state'] in ('OPEN', 'DEGRADED') else 'INACTIVE') + close(observed['operation_state'], operation, 'rest operation') + occupied = facility['occupied_cells'] or [facility['origin']] + probes = [dict(cell=[c[0] + dx, c[1], c[2]], has_cell=(c[0] + dx, c[1], c[2]) in passable) + for c in occupied for dx in (0, -1, 1)] + close(observed['probes'], probes, 'rest candidate probes') + ordered = sorted({tuple(p['cell']) for p in probes if p['has_cell']}, key=lambda c: (c[2], c[1], c[0])) + close(observed['ordered'], [list(c) for c in ordered], 'rest candidate order') + require([r['cell'] for r in observed['routes']] == [list(c) for c in ordered], 'rest route coverage/order') + best = None + for route in observed['routes']: + path, edges = route['path'], route['edges'] + require(len(edges) == max(len(path) - 1, 0), 'rest edge coverage') + if not path: + require(route['calculate_path_cost'] == -1, 'rest empty route cost') + continue + require(path[0] == member['current_cell'] and path[-1] == route['cell'], 'rest path endpoints') + require(all(tuple(c) in passable for c in path), 'rest path cell absent') + cost = 0 + for a, b, edge in zip(path, path[1:], edges): + require(edge and edge['from'] == a and edge['to'] == b, 'rest empty or discontinuous edge report') + require(type(edge['total_cost']) is int and edge['total_cost'] >= 0, 'rest edge cost') + cost += edge['total_cost'] + require(cost == route['calculate_path_cost'], 'rest edge sum differs from calculate_path_cost') + if best is None or cost < best[0]: + best = (cost, path[-1]) + if not eligible or operation != 'OPERATING' or facility['definition_id'] not in ('staff_waiting_room', 'staff_dormitory') or best is None or best[0] >= best_cost: + continue + best_cost = best[0] + cell = member['current_cell'] + at = any(c[2] == cell[2] and c[1] == cell[1] and abs(c[0] - cell[0]) <= 1 for c in occupied) and not transit + selected = dict(reachable=True, resting=at, cell=best[1], facility_id=facility['instance_id'], + label=('宿舎で休憩中' if facility['definition_id'] == 'staff_dormitory' else '待機室で休憩中') if at else '休憩先へ移動中') + close(row['comparison_only'], selected, 'rest selection comparison') + selections[sid] = selected + if selected.get('resting'): + resting[sid] = selected['facility_id'] + close(report['resting_staff'], resting, 'draw resting_staff') + transform = Transform(runtime, report) + drawings = {r['key']: r for r in report['actor_drawings']} + for sid, fid in resting.items(): + member = next(m for m in members if m['staff_id'] == sid) + if member['current_cell'][2] != report['layer'] or sid in report['carried_patients']: + continue + room = report['rooms'].get(fid, {}) + cells = next((r['cells'] for r in runtime['world']['occupancies'] if r['entity_id'] == fid), []) + if not cells: + continue # renderer uses its ordinary tracked fallback + require(sid in room.get('people', []), 'rest room occupant missing') + slot = room['people'].index(sid) + anchors = room_slots(transform.room(cells), transform.dim[0] / 16) + key = 'staff/' + sid + if key in report['boarded'] or key in report['queue_hidden']: + require(key not in drawings, 'hidden resting drawing') + elif slot >= len(anchors): + require(key not in drawings, 'rest slot overflow drawing') + else: + close(drawings[key]['center'], [anchors[slot][0], anchors[slot][1] - transform.dim[1] * .5], + 'resting staff position') + return selections + + def compare_goods(snapshot, report, root=ROOT): runtime = snapshot['systems']['runtime'] states = enum_names(root, 'scripts/inventory/logistics_request.gd', 'State') @@ -356,6 +661,42 @@ def compare_goods(snapshot, report, root=ROOT): close(report['goods'], dict(requests=rows, deliveries=active + [r[2] for r in recent[:10]]), 'goods') +def compare_badges(state, report): + runtime = state['systems']['runtime'] + transform = Transform(runtime, report) + expected = [] + labels = dict(BROKEN='破損', NO_ACCESS='通路なし', NO_POWER='魔力なし', NO_STAFF='職員不足', + CLOSED='営業停止', DEGRADED='低下運転', OPEN='営業中') + for facility in runtime['facilities']['instances']: + fid = facility['instance_id'] + if facility['origin'][2] != report['layer']: continue + cells = next((r['cells'] for r in runtime['world']['occupancies'] if r['entity_id'] == fid), []) + if not cells: continue + x,y,w,h = transform.room(cells) + room = report['rooms'][fid] + count, limit = room['count'], facility['customer_capacity'] + if facility['suspension_reason']: + business = '休止・摘発' if facility['suspension_reason'] == 'RAIDED' else '休止・破壊' + label = business + else: + business = labels.get(facility['state'], facility['state']) + if facility['state'] not in ('OPEN', 'DEGRADED'): + label = '未稼働' + else: + label = f'利用{count}/{limit}人' if limit >= 0 else f'利用{count}人' + if limit > 0 and count >= limit: label = '満員 ' + label + visible = len(room_slots([x,y,w,h], transform.dim[0]/16, facility['definition_id'])) + if count > visible: label += f'(他{count-visible}人)' + expected.append(dict(facility=fid, kind='business', at=[x+2,y+2], text=business+' '+label)) + requests = [r for r in report['goods']['requests'] if fid in (r['source_facility_id'], r['destination_facility_id'])] + if requests: + states = {} + for r in requests: states[r['state']] = states.get(r['state'], 0)+1 + text = '物資依頼 ' + ' / '.join(f'{k}:{states[k]}' for k in sorted(states)) + expected.append(dict(facility=fid, kind='supply', at=[x+2,y+16], requests=requests, text=text)) + close(report['badges'], expected, 'business/supply badges') + + def compare_cabin_geometry(snapshot, report): runtime = snapshot['systems']['runtime'] transform = Transform(runtime, report) @@ -394,6 +735,173 @@ def compare_cabin_geometry(snapshot, report): close(row[key], value, 'cabin/' + key) +def compare_motion(state, report, audit): + frames = audit['frames'] + require(len(frames) == 2, 'two motion history frames required') + previous, current = frames + require(type(previous['frame']) is int and current['frame'] > previous['frame'], 'motion frame order') + require(current['delta'] >= 0 and current['scaled_delta'] >= 0, 'motion delta') + before, after = previous['display'], current['display'] + for key, value in after.items(): + close(report[key], value, 'motion report binding/' + key) + close(after['animation_time'], before['animation_time'] + current['scaled_delta'], 'motion animation delta') + if before['paused'] and after['paused']: + close(current['scaled_delta'], 0, 'paused animation moved') + now = after['animation_time'] + runtime = state['systems']['runtime'] + cells = {'guest/' + r['guest_id']: r['current_cell'] for r in runtime['guests']['guests']} + cells.update({'staff/' + r['staff_id']: r['current_cell'] for r in runtime['staff']['staff']}) + def interpolated(track, endpoint, paused, transform=None): + t = 1 if paused else max(0, min(1, (now-track['at'])/.42)) + if transform: + a, b = transform.center(track['from']), transform.center(endpoint) + return [a[i] + (b[i]-a[i])*t for i in (0, 1)] + return track['from']+(endpoint-track['from'])*t + for key, track in after['actor_tracks'].items(): + require(key in cells and track['cell'] == cells[key], 'motion actor endpoint') + old = before['actor_tracks'].get(key) + if old is None: + wanted = dict(cell=cells[key], **{'from': cells[key]}, at=now, direction=1.0) + elif old['cell'] == cells[key]: + wanted = old + else: + a, b = old['cell'], cells[key] + origin = a if a[2] == b[2] and abs(a[0]-b[0])+abs(a[1]-b[1]) == 1 else b + wanted = dict(cell=b, **{'from': origin}, at=now, + direction=(1.0 if b[0] > a[0] else -1.0) if a[0] != b[0] else old['direction']) + close(track, wanted, 'actor interpolation origin/history') + # Ordinary walking centers; queue/room/rescue/working projections have + # independent semantic placement checks and do not use this center. + drawing = next((a for a in after['actor_drawings'] if a['key'] == key), None) + if drawing and drawing['mode'] in ('walk', 'carry_walk') and key not in report['queue_positions']: + position = interpolated(track, cells[key], after['paused'], Transform(runtime, report)) + # Same-cell crowd offsets are handled by the complete actor check. + require(all(math.isfinite(v) for v in position), 'nonfinite interpolation') + floors = {s['shaft_id']: s['visual_floor'] for s in elevator_report(state)['shafts']} + for key, track in after['elevator_tracks'].items(): + require(key in floors, 'unknown elevator motion') + old = before['elevator_tracks'].get(key) + if old is None: + wanted = dict(floor=floors[key], **{'from': floors[key]}, at=now) + elif math.isclose(old['floor'], floors[key], abs_tol=.00001, rel_tol=.00001): + wanted = old + else: + wanted = dict(floor=floors[key], **{'from': old['floor']}, at=now) + close(track, wanted, 'elevator interpolation origin/history') + if before['paused'] and after['paused'] and all(before[k] == after[k] for k in ('layer', 'zoom', 'pan', 'viewport', 'actor_tracks', 'elevator_tracks')): + prior_actors = {r['key']: r for r in before['actor_drawings']} + for row in after['actor_drawings']: + old = prior_actors.get(row['key']) + if old and all(old[k] == row[k] for k in ('state', 'mode', 'activity', 'receiving', 'carrying')): + close(row['at'], old['at'], 'paused actor moved') + return ['motion_history', 'interpolation', 'pause'] + + +def compare_actor_geometry(state, report): + runtime = state['systems']['runtime'] + transform = Transform(runtime, report) + p, height = transform.dim[0]/16, transform.dim[1] + facilities = codec.indexed(runtime['facilities']['instances'], 'instance_id') + staff = codec.indexed(runtime['staff']['staff'], 'staff_id') + occupancy = {r['entity_id']: r['cells'] for r in runtime['world']['occupancies']} + counts, expected = {}, [] + def track_position(key, cell): + track = report['actor_tracks'][key] + close(track['cell'], cell, 'actor authoritative cell') + t = 1 if report['paused'] else max(0, min(1, (report['animation_time']-track['at'])/.42)) + a, b = transform.center(track['from']), transform.center(cell) + return [a[i]+(b[i]-a[i])*t for i in (0, 1)] + def offset(cell, center): + key = tuple(cell) + n = counts.get(key, 0) + counts[key] = n+1 + return [center[0]+[0,4,-4,2,-2,6,-6][n%7]*p, center[1]-(n%2)*p] + def slot_position(fid, identifier, definition, fallback, dy, missing_fallback=False): + cells = occupancy.get(fid, []) + if not cells: + return fallback + users = report['rooms'].get(fid, {}).get('people', []) + if identifier not in users: + return fallback if missing_fallback else None + anchors = room_slots(transform.room(cells), p, definition) + index = users.index(identifier) + return [anchors[index][0], anchors[index][1]+dy] if index < len(anchors) else None + for guest in runtime['guests']['guests']: + key, cell = 'guest/'+guest['guest_id'], guest['current_cell'] + if key in report['boarded'] or key in report['queue_hidden'] or cell[2] != report['layer']: + continue + center = track_position(key, cell) + if key in report['queue_positions']: + center = report['queue_positions'][key] + elif guest['current_service_id']: + fid = guest['target_facility_id'] + definition = facilities.get(fid, {}).get('definition_id', '') + cells = occupancy.get(fid, []) + if cells and cells[0][2] == report['layer']: + center = slot_position(fid, guest['guest_id'], definition, center, + 3*p-height*.5-(5*p if definition in ('standard_room', 'premium_room') else 0), True) + else: + center = offset(cell, center) + if center is not None: + expected.append((key, center, False, bool(guest['current_service_id']), False, False)) + for sid, member in staff.items(): + key, cell = 'staff/'+sid, member['current_cell'] + if key in report['boarded'] or key in report['queue_hidden'] or cell[2] != report['layer']: + continue + center = track_position(key, cell) + special = True + if sid in report['carried_patients']: + carrier = staff.get(report['carried_patients'][sid]) + if carrier: + center = track_position('staff/'+carrier['staff_id'], carrier['current_cell']) + center[1] -= 12*p + elif sid in report['resting_staff']: + center = slot_position(report['resting_staff'][sid], sid, 'staff_waiting_room', center, -height*.5) + elif sid in report['treated_patients']: + center = slot_position(report['treated_patients'][sid], sid, 'infirmary', center, -2*p-height*.5) + else: + special = False + center = offset(cell, center) + fid = member['assigned_facility_id'] + cells = occupancy.get(fid, []) + workers = report['rooms'].get(fid, {}).get('staff', []) + if member['state'] in ('WORKING', 'AVAILABLE') and fid in facilities and cell in cells and sid in workers: + rect = transform.room(cells) + columns = max(int(rect[2]/(16*p)), 1) + index = workers.index(sid) + if rect[3] >= 40*p and index < columns: + center = [rect[0]+(index+.5)*rect[2]/columns, rect[1]+26*p-height*.5] + center = report['queue_positions'].get(key, center) + if center is not None: + expected.append((key, center, True, False, bool(member['carried_inventory_id']) and not special, False)) + require([r['key'] for r in report['actor_drawings']] == [r[0] for r in expected], 'actor drawing coverage/order') + pairs = list(zip(report['actor_drawings'], expected)) + shafts = {s['shaft_id']: s for s in elevator_report(state)['shafts']} + for cabin in report['cabins']: + shaft = shafts[cabin['shaft_id']] + x,y,w,h = cabin['rect'] + columns = max(int((w/p-4)/8), 1) + passengers = shaft['boarded'][:columns*2] + require(len(cabin['passengers']) == len(passengers), 'passenger coverage') + for i, (actor_id, row) in enumerate(zip(passengers, cabin['passengers'])): + key = actor_id.replace('staff:', 'staff/', 1).replace('guest:', 'guest/', 1) + is_staff = key.startswith('staff/') + center = [x+(2+(i%columns+.5)*8)*p, y+h-(2+(i//columns)*9)*p+3*p-height*.5] + carried = bool(staff.get(key.removeprefix('staff/'), {}).get('carried_inventory_id')) if is_staff else False + pairs.append((row, (key, center, is_staff, False, carried, True))) + for row, (key, center, is_staff, receiving, carrying, riding) in pairs: + close(row['center'], center, 'actor center/interpolation') + scale = .5 if riding else .75 + scale = max(math.floor(p*scale), 1) if p >= 2 else p*scale + close(row['scale'], scale, 'actor scale') + at = [center[0]-8*scale, center[1]+height*.5-3*p-17*scale] + rounded = [math.copysign(math.floor(abs(v)+.5), v) for v in at] + close(row['at'], rounded, 'actor drawing position') + require((row['key'], row['staff'], row['receiving'], row['carrying'], row['riding']) == + (key, is_staff, receiving, carrying, riding), 'actor drawing attributes') + return ['actor_geometry', 'passenger_geometry'] + + def compare_layouts(a, b): require(a['declaration'] == b['declaration'], 'identical predeclared inputs') declaration = a['declaration'] @@ -452,30 +960,339 @@ def inspect_stream(directory, root=ROOT): labels.add(row['label']) require(type(row['pid']) is int and row['pid'] > 0, 'actual diagnostic PID required') state = read_object(directory, row['state']) + close(read_object(directory, row['state_after']), state, 'read-only audit snapshot identity') report = read_object(directory, row['report']) fields = compare_actor_rows(state, report, root) require(report['elevators'] == elevator_report(state), 'typed elevator report mismatch') compare_room_services(state, report, root) + compare_rest(state, report, read_object(directory, row['rest_audit']), root) compare_queues(state, report) compare_traffic(state, report) compare_goods(state, report, root) + compare_badges(state, report) compare_cabin_geometry(state, report) + compare_motion(state, report, read_object(directory, row['motion'])) + compare_actor_geometry(state, report) checked.append(dict(label=row['label'], fields=fields + ['elevators', 'room_service_fields', 'queues', 'traffic', 'goods', 'cabin_geometry'])) require(checked, 'empty diagnostic stream') return checked -def condition_matrix(): - return [dict(condition=name, status='blocked', reason=BLOCKERS[name]) for name in CONDITIONS] +def condition_matrix(executed=None): + executed = executed or {} + result = [] + for condition in CONDITIONS: + categories = {} + for category in CATEGORIES: + actual = set(executed.get(condition, {}).get(category, [])) + required = REQUIRED_CHECKS[condition][category] + missing = sorted(required - actual) + categories[category] = dict(checks=sorted(actual), missing=missing, + status='passed' if actual and not missing else 'pending') + result.append(dict(condition=condition, categories=categories, + status='passed' if all(c['status'] == 'passed' for c in categories.values()) else 'pending')) + return result + + +def BLOCKERS(executed=None): + return {r['condition']: {c: d['missing'] for c, d in r['categories'].items() if d['missing']} + for r in condition_matrix(executed) if r['status'] != 'passed'} + + +CONTRACT = 'docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json' +PLAN = 'docs/plans/original-completion-2026-10-02/IMPLEMENTATION_PLAN.md' + + +def normative_plan_text(root): + """Requirement text is an input; explicitly labelled progress is metadata.""" + text = (root / PLAN).read_text() + section = re.search(r'^### P23[^\n]*\n(.*?)(?=^### |\Z)', text, re.M | re.S) + require(section is not None, 'missing normative #p23 plan section') + lines = [] + for line in section.group(0).splitlines(): + if line.startswith('現在地:'): + continue + line = line.split(' 状態:', 1)[0].rstrip() + if line: + lines.append(line) + require(any(line.startswith('- P23-03 ') and '受入:' in line for line in lines), + 'missing P23-03 work/acceptance plan text') + return '\n'.join(lines) + '\n' + + +def input_map(root): + names = [SPEC, CONTRACT, 'AGENTS.md', 'docs/traceability/original/BASELINE.txt'] + names += [p.relative_to(root).as_posix() for p in (root / 'tests/fixtures/p23-03').rglob('*') + if p.is_file()] + pins = {name: sha(root / name) for name in sorted(names)} + pins[PLAN + '#p23-normative'] = hashlib.sha256(normative_plan_text(root).encode()).hexdigest() + return pins + + +def input_provenance(root): + # Full-plan progress changes deliberately do not participate in input equality. + return {'full_plan_sha256': sha(root / PLAN)} + + +def inspect_os_receipt(receipt, source_map, pins=None): + require(receipt.get('engine_ended') is True, 'engine_ended must be true') + require(receipt.get('passed') is True and receipt.get('exit_code') == 0, 'unsuccessful OS process') + require(not receipt.get('error') and receipt.get('guard_errors') == [] + and receipt.get('cleanup_errors') == [], 'OS cleanup/guard failure') + require(receipt.get('default_before') == receipt.get('default_after') + and isinstance(receipt.get('default_before'), dict), 'default profile changed') + require(receipt.get('stage_before') == receipt.get('stage_after') + and isinstance(receipt.get('stage_before'), dict), 'stage changed') + require(receipt.get('source_map') == source_map, 'OS source pin differs') + after = receipt.get('source_after', {}) + require({name: pin['sha256'] for name, pin in after.items()} == source_map, 'source changed') + cleanup = receipt.get('owned_cleanup') + require(isinstance(cleanup, list) and cleanup + and all(row.get('engine_ended') is True for row in cleanup), 'missing owned cleanup') + require(bool(receipt.get('ordinary_owner_proof')), 'missing ordinary audio owner proof') + for field in ('engine_sha256', 'settings_sha256', 'environment_sha256'): + require(isinstance(receipt.get(field), str) and re.fullmatch('[0-9a-f]{64}', receipt[field]) + and receipt[field] == receipt.get(field + '_after'), 'OS pin changed: ' + field) + require(receipt.get('spec_sha256') == SPEC_SHA, 'OS immutable spec') + require(receipt.get('inputs') and receipt['inputs'] == receipt.get('inputs_after'), 'OS inputs changed') + if pins is not None: + require(receipt['inputs'] == pins, 'OS input applicability') + require(receipt.get('environment') and receipt['environment'] == receipt.get('environment_after'), 'OS environment changed') + require(type(receipt.get('actual_pid')) is int and receipt['actual_pid'] > 0, 'OS actual PID') + require(receipt.get('profile') and receipt.get('profile_name') and isinstance(receipt.get('profile_after'), dict), 'OS private profile provenance') + before = receipt.get('profile_before', {}) + require(type(before.get('existed')) is bool and isinstance(before.get('files'), dict), 'OS profile before') + for member, pin in before['files'].items(): + require(receipt['profile_after'].get(member) == pin, 'OS profile input changed: ' + member) + + +def inspect_resume(writer, reader, saved, requests, formal_members): + require(type(writer['actual_pid']) is int and type(reader['actual_pid']) is int + and writer['actual_pid'] > 0 and reader['actual_pid'] > 0 + and writer['actual_pid'] != reader['actual_pid'], 'resume requires distinct actual PIDs') + require(saved['pid'] == writer['actual_pid'], 'formal writer PID') + require(set(formal_members) == {'world.bin', 'manifest.json', 'thumbnail.png', 'journal.log'}, 'formal members coverage') + close(saved['members'], formal_members, 'formal member hashes') + require({k: v['sha256'] for k, v in reader['formal_input'].items()} == formal_members, 'reader formal input hashes') + loads = [row for row in requests if row['request']['action'] == 'load_menu'] + require(len(loads) == 1, 'missing or duplicate normal load ACK') + item = loads[0] + request, ack, observed, cua = [item[k] for k in ('request', 'ack', 'observed', 'cua')] + require(request['target_pid'] == ack['target_pid'] == observed['pid'] == cua['target_pid'] == reader['actual_pid'], 'load ACK PID') + require(request['sequence'] == ack['sequence'] == observed['sequence'], 'load ACK request ID') + require(ack['request_sha256'] == item['request_sha256'] == observed['request_sha256'], 'load ACK hash') + require(0 < request['requested_ticks_usec'] < observed['ack_observed_ticks_usec'], 'load ACK time order') + require(cua['method'] == 'native_os' and cua['tool_call_id'] and cua['timestamp_ns'] > 0, 'load CUA provenance') + require(request['requested_unix_ns'] <= cua['timestamp_ns'] <= ack['timestamp_ns'] <= observed['ack_observed_unix_ns'], 'load external ACK time order') + require(observed['status'] == '手動セーブを読み込みました。', 'normal load result') + return ['distinct_pid', 'formal_members', 'load_ack'] + + +def inspect_os_requests(directory, layout, role, pid, root=ROOT): + pair = directory/'os-pair'/layout + def path(name): + result = directory/name + require(result.resolve().is_relative_to(directory.resolve()) and not result.is_symlink(), 'OS artifact escape') + return result + def document(name): + return json.loads(path(name).read_bytes()) + listing = json.loads((pair/(role+'-requests.json')).read_bytes()) + require(listing['pid'] == pid and listing['requests'] and len(set(listing['requests'])) == len(listing['requests']), 'OS request list') + bindings = json.loads((pair/(role+'-screenshots.json')).read_bytes()) + seen, records, pixel_categories = set(), [], set() + stream = pair/(role+'-stream') + captures = {row['label']: row for row in map(json.loads, (stream/'manifest.jsonl').read_text().splitlines()) if 'report' in row} + prior_tick = -1 + for sequence, ack_name in enumerate(listing['requests'], 1): + ack_path = path(ack_name) + request_path = ack_path.with_name('request.json') + observed = json.loads(ack_path.with_name('observed.json').read_bytes()) + request, ack = json.loads(request_path.read_bytes()), json.loads(ack_path.read_bytes()) + cua = document(ack['input_receipt']) + require(request['sequence'] == ack['sequence'] == observed['sequence'] == sequence, 'OS request sequence') + require(request['target_pid'] == ack['target_pid'] == observed['pid'] == cua['target_pid'] == pid, 'OS request PID') + digest = sha(request_path) + require(ack['request_sha256'] == observed['request_sha256'] == digest, 'OS ACK hash') + require(prior_tick < request['requested_ticks_usec'] < observed['ack_observed_ticks_usec'], 'OS ACK order') + prior_tick = observed['ack_observed_ticks_usec'] + require(request['requested_unix_ns'] <= cua['timestamp_ns'] <= ack['timestamp_ns'] <= observed['ack_observed_unix_ns'], 'OS external ACK time order') + require(cua['method'] == 'native_os' and cua['tool_name'] and cua['tool_call_id'] + and cua['timestamp_ns'] > 0 and cua['collector_pid'] > 0 and cua['collector_pid'] != pid, 'OS CUA provenance') + require('arguments' in cua and isinstance(cua['arguments'], dict), 'full CUA arguments required') + if request['action'] != 'screenshot': + require(cua['events'] and observed['events'], 'OS input events missing') + if request['action'] == 'wheel': + require(request['ctrl'] is False and any(e.get('ctrl') is False and e.get('pressed') for e in observed['events']), 'ordinary wheel evidence') + report = decode(request['report']) + capture = captures[request_path.parent.name] + close(read_object(stream, capture['report']), report, 'request draw report binding') + for number, shot in enumerate(cua['screenshots']): + matches = [b for b in bindings if b['request'] == str(request_path.relative_to(directory)) and b['screenshot_index'] == number] + require(len(matches) == 1, 'screenshot binding coverage') + binding = matches[0] + identity = (binding['request'], number) + require(identity not in seen, 'duplicate screenshot binding') + seen.add(identity) + require(binding['request_sha256'] == digest and binding['target_pid'] == pid and binding['sequence'] == sequence, + 'screenshot request attribution') + require(binding['input_receipt'] == ack['input_receipt'] and binding['input_receipt_sha256'] == sha(path(ack['input_receipt'])), 'screenshot CUA receipt hash') + require(binding['raw_path'] == shot['path'] and binding['raw_sha256'] == shot['raw_sha256'] == sha(path(shot['path'])) + and binding['tool_call_id'] == shot['tool_call_id'] and shot['tool_call_id'], 'screenshot raw provenance') + conversion = document(binding['conversion']) if binding['conversion'] else None + image = screenshot_pixels(path(shot['path']), conversion, shot) + require(binding['png_sha256'] == sha(path(binding['png_path'])) and (binding['width'], binding['height']) == image[:2], 'decoded screenshot binding') + if conversion: + require(Path(conversion['png_path']).resolve() == path(binding['png_path']).resolve(), 'converted output binding') + for suffix in ('stdout', 'stderr'): + require(sha(path(binding['conversion']+'.'+suffix)) == conversion[suffix+'_sha256'], 'converter log hash') + else: + require(binding['png_path'] == binding['raw_path'], 'PNG must bind original bytes') + # Screenshot actions observe a stable report; input before/after + # screenshots are provenance only and never relabelled as this frame. + if request['action'] == 'screenshot': + checked = compare_rectangles(report, decode(request['rectangles']), request['local_to_viewport'], + request['viewport_to_screen'], request['local_to_screen'], image, root) + pixel_categories.update(c.split('/')[0] for c in checked) + records.append(dict(request=request, ack=ack, observed=observed, cua=cua, request_sha256=digest)) + require(len(seen) == len(bindings), 'unattributed screenshot bindings') + return records, pixel_categories + + +def inspect_native_phases(directory, index, root=ROOT): + runner = module('p23_existing_runner_checks', root/'tools/run_all_tests.py') + expected = ['runs/'+name+'/'+name+'-receipt.json' for name in ('import', 'parse', 'layout-A', 'layout-B')] + require(index['phases'] == expected, 'native phase coverage/order') + for name in expected: + path = directory/name + receipt = json.loads(path.read_bytes()) + pins = json.loads(path.with_name('p23-inputs.json').read_bytes()) + require(pins['receipt_sha256'] == sha(path), 'native receipt pin') + require(pins['source_map'] == index['source_map'] and {k: v['sha256'] for k, v in pins['source_after'].items()} == index['source_map'], 'native sources changed') + require(pins['inputs'] == pins['inputs_after'] == input_map(root) and pins['spec_sha256'] == SPEC_SHA, 'native input applicability') + require(pins['default_before'] == pins['default_after'] and pins['stage_before'] == pins['stage_after'], 'native profile/stage changed') + require(pins['settings_sha256'] == sha(root/'project.godot'), 'native settings changed') + require(receipt['passed'] is True and receipt['failed'] is False and receipt['engine_ended'] is True + and receipt['exit_code'] == 0 and not receipt.get('validation_error') and not receipt.get('guard_error'), 'native process failed') + capture = receipt['outer_actual_capture'] + require(capture['passed'] is True and capture['all_captured_ended'] is True, 'native owned cleanup') + require(receipt['whole_startup_profile_after']['fresh_before_engine'] is True, 'native profile protection') + require(receipt['default_player_entire_profile_unchanged'] is True, 'native default profile changed') + require(isinstance(receipt['controlled_environment'], dict), 'native environment absent') + diagnostics = receipt['finite_original_diagnostic_review'] + require(runner.process_gate(receipt['native_process'], diagnostics['allowed_e31_error_lines']), 'native process gate') + if receipt['test']: + require(receipt['source_sha256'] == receipt['source_sha256_end'] == sha(root/receipt['test']) + and receipt['completion']['passed'] is True and receipt['reported_checks'] > 0 and receipt['reported_failures'] == 0, 'native strict marker') + + +def check(root, directory): + """Never infer completion of unimplemented gates from an index assertion.""" + deficits = [] + executed = {c: {category: set() for category in CATEGORIES} for c in CONDITIONS} + semantic, pixel_categories, anchors = set(), set(), set() + index_path = directory / 'index.json' + if not index_path.is_file(): + return ['missing evidence index: ' + str(index_path)] + try: + index = json.loads(index_path.read_bytes()) + runner = module('p23_source_applicability', root/'tools/run_all_tests.py') + require(index.get('source_map') == {n: v['sha256'] for n, v in runner.source_refs(root).items()}, 'exact frozen source map') + if index.get('input_map') != input_map(root): + deficits.append('normative input_map differs from current immutable inputs') + if index.get('spec_sha256') != SPEC_SHA: + deficits.append('immutable spec SHA missing or different') + if index.get('marker_contract_sha256') != sha(root / CONTRACT): + deficits.append('marker contract SHA missing or different') + artifacts = index.get('artifacts', {}) + if not artifacts: + deficits.append('missing indexed evidence artifacts') + for name, digest in artifacts.items(): + path = directory / name + require(path.resolve().is_relative_to(directory.resolve()) and not path.is_symlink(), + 'artifact escapes evidence directory') + require(path.is_file() and sha(path) == digest, 'artifact SHA mismatch: ' + name) + inspect_native_phases(directory, index, root) + for layout in ('A', 'B'): + layout_semantic, layout_pixels = set(), set() + stream = directory / ('layout-' + layout) + if (stream / 'manifest.jsonl').is_file(): + inspect_stream(stream, root) + else: + deficits.append('missing native layout-' + layout + ' typed diagnostic stream') + for role in ('writer', 'reader'): + stream = directory / 'os-pair' / layout / (role + '-stream') + if (stream / 'manifest.jsonl').is_file(): + inspect_stream(stream, root) + # Successful independent recalculation, with nonempty + # observed rows required before a category earns coverage. + for line in (stream/'manifest.jsonl').read_text().splitlines(): + capture = json.loads(line) + if 'report' not in capture: continue + report = read_object(stream, capture['report']) + presence = {'room': report['rooms'], 'queue': report['landing_badges'], + 'EV': report['cabins'], 'staff': report['staff'], + 'goods': report['goods']['requests'], 'business': report['badges']} + semantic.update('semantic_'+c for c, rows in presence.items() if rows) + layout_semantic.update(c for c, rows in presence.items() if rows) + if any(r['people'] for r in report['rooms'].values()): anchors.add('room_users') + if any(r['overflow'] > 0 for r in report['landing_badges']): anchors.add('queue_overflow') + if any(r['passengers'] for r in report['cabins']): anchors.add('elevator_passengers') + if report['staff'] and report['tasks']: anchors.add('staff_jobs') + if any(r['carried_inventory_id'] for r in report['staff']) and report['goods']['requests']: anchors.add('carried_goods') + if role == 'reader': anchors.add('resume_recalculation') + else: + deficits.append('missing OS ' + layout + '-' + role + ' typed diagnostic stream') + ended = directory / 'os-runs' / (layout + '-' + role) / 'ended.json' + if ended.is_file(): + receipt = json.loads(ended.read_bytes()) + inspect_os_receipt(receipt, index.get('source_map'), input_map(root)) + records, categories = inspect_os_requests(directory, layout, role, receipt['actual_pid'], root) + pixel_categories.update('pixels_'+c for c in categories if c in CATEGORIES) + layout_pixels.update(categories) + if role == 'reader': + writer = json.loads((directory/'os-runs'/(layout+'-writer')/'ended.json').read_bytes()) + pair = directory/'os-pair'/layout + members = {p.name: sha(p) for p in (pair/'formal').iterdir() if p.is_file()} + inspect_resume(writer, receipt, json.loads((pair/'save.json').read_bytes()), records, members) + require((pair/'writer-formal-model.var').read_bytes() == (pair/'reader-formal-model.var').read_bytes(), 'resumed whole formal model') + anchors.add('distinct_pid_resume') + else: + deficits.append('missing OS ' + layout + '-' + role + ' process receipt') + for category in CATEGORIES: + if category not in layout_semantic or category not in layout_pixels: + deficits.append('missing actual OS ' + layout + ' semantic/pixel coverage: ' + category) + if all((directory / ('layout-' + layout) / 'manifest.jsonl').is_file() for layout in ('A', 'B')): + compare_layouts(comparison_records(directory / 'layout-A'), comparison_records(directory / 'layout-B')) + anchors.add('route_improvement') + if not deficits: + anchors.add('os_guards') + for condition in CONDITIONS: + for category in CATEGORIES: + executed[condition][category].update((semantic | pixel_categories | anchors) & REQUIRED_CHECKS[condition][category]) + deficits.extend(condition + ': ' + json.dumps(missing, ensure_ascii=False) for condition, missing in BLOCKERS(executed).items()) + except (OSError, ValueError, KeyError, TypeError, AttributeError) as exc: + deficits.append('invalid evidence index: ' + str(exc)) + return deficits def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--verify-only', action='store_true') + parser.add_argument('--project-root', type=Path, default=ROOT) parser.add_argument('--diagnostics', type=Path) parser.add_argument('--layout-a', type=Path) parser.add_argument('--layout-b', type=Path) args = parser.parse_args(argv) + if args.verify_only: + build = os.environ.get('BUILD_OUTPUT_DIR') + if not build: + print('BUILD_OUTPUT_DIR is required', file=sys.stderr) + return 2 + deficits = check(args.project_root, Path(build) / 'p23-life-observation-evidence') + print(json.dumps(dict(task_id='P23-03', integrity='fail' if deficits else 'pass', + deficits=deficits, acceptance='pending'), ensure_ascii=False)) + return 1 if deficits else 0 # Registration hashes the immutable JSON after trimming outer whitespace. require(hashlib.sha256((ROOT / SPEC).read_text().strip().encode()).hexdigest() == SPEC_SHA, 'immutable spec mismatch') diff --git a/tools/run_p23_life_observation.py b/tools/run_p23_life_observation.py index 25d2cbd..fe5fc72 100644 --- a/tools/run_p23_life_observation.py +++ b/tools/run_p23_life_observation.py @@ -1,25 +1,354 @@ #!/usr/bin/env python3 -"""Read-only P23 readiness report. Collection is blocked in this candidate. - -No runner, engine, profile, subprocess, browser or OS input is started. Root must -not mistake this preparation interface for a submitted native/OS collector. -""" +"""Root-only native collection. Does not run regression or claim acceptance.""" +import argparse +import hashlib +import importlib.util import json +import os +from pathlib import Path +import shutil +import subprocess import sys +import time +import uuid +import re sys.dont_write_bytecode = True -from check_p23_life_observation_evidence import condition_matrix +HERE = Path(__file__).resolve().parent +CONTRACT = 'docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json' +CONDITIONS = ('room_occupancy_and_operation', 'queues_and_overflow', + 'elevator_state_and_passengers', 'staff_state_and_jobs', + 'supplies_and_carried_goods', 'same_demand_route_improvement', + 'normal_os_and_distinct_pid_resume') +OS_CHILD_TIMEOUT_SECONDS = 900 +RECORDER = 'tests/fixtures/p23-03/passive_os_recorder.gd' +def collect_screenshots(out, pair, role): + """Bind every external CUA screenshot to its request and decoded PNG. -def main(): - print(json.dumps({'task_id': 'P23-03', 'status': 'blocked', 'engine_runs': 0, - 'condition_matrix': condition_matrix(), - 'required_infrastructure': ['run_all_tests.py', 'strict_test_markers.py', - 'regression/owned_outer.py', - 'regression/ordinary_proof_gate.py'], - 'root_evidence': 'missing'}, ensure_ascii=False)) - return 2 + The external receipt supplies screenshots[{path,raw_sha256,tool_call_id}]. + Paths are relative to the evidence root; raw receipt bytes remain unchanged. + """ + checker = module('p23_screenshot_binding', HERE/'check_p23_life_observation_evidence.py') + bindings = [] + requests = json.loads((pair/(role+'-requests.json')).read_text())['requests'] + for ack_name in requests: + ack_path = out/ack_name + checker.require(ack_path.resolve().is_relative_to(out.resolve()), 'ACK path escape') + ack = json.loads(ack_path.read_text()) + request_path = ack_path.with_name('request.json') + request = json.loads(request_path.read_text()) + receipt_path = out/ack['input_receipt'] + checker.require(receipt_path.resolve().is_relative_to(out.resolve()), 'CUA receipt path escape') + cua = json.loads(receipt_path.read_text()) + checker.require(ack['request_sha256'] == checker.sha(request_path), 'CUA request hash') + checker.require(cua['screenshots'], 'CUA screenshots missing') + for number, shot in enumerate(cua['screenshots']): + raw = out/shot['path'] + checker.require(raw.resolve().is_relative_to(out.resolve()) and not raw.is_symlink(), 'screenshot path escape') + checker.require(checker.sha(raw) == shot['raw_sha256'] and shot['tool_call_id'], 'raw screenshot CUA binding') + kind, _, _ = checker.image_kind(raw.read_bytes()) + conversion = None + if kind == 'jpeg': + png = ack_path.with_name('decoded-%02d.png' % number) + receipt = ack_path.with_name('conversion-%02d.json' % number) + convert_screenshot(raw.resolve(), png.resolve(), shot, receipt.resolve()) + conversion = str(receipt.relative_to(out)) + else: + png = raw + width, height, _ = checker.png_pixels(png.read_bytes()) + bindings.append(dict(request=str(request_path.relative_to(out)), request_sha256=checker.sha(request_path), + sequence=request['sequence'], target_pid=request['target_pid'], + input_receipt=ack['input_receipt'], input_receipt_sha256=checker.sha(receipt_path), + screenshot_index=number, tool_call_id=shot['tool_call_id'], + raw_path=shot['path'], raw_sha256=shot['raw_sha256'], conversion=conversion, + png_path=str(png.relative_to(out)), png_sha256=checker.sha(png), width=width, height=height)) + checker.require(bindings, 'empty screenshot collection') + destination = pair/(role+'-screenshots.json') + with destination.open('x') as output: + json.dump(bindings, output, indent=2) + output.write('\n') + return str(destination.relative_to(out)) +def convert_screenshot(raw_path, png_path, cua_receipt, receipt_path): + """Root-only conversion; raw CUA bytes are never renamed or rewritten.""" + checker = module('p23_conversion_checker', HERE/'check_p23_life_observation_evidence.py') + raw, png, receipt = map(Path, (raw_path, png_path, receipt_path)) + checker.require(raw.is_absolute() and png.is_absolute() and receipt.is_absolute(), + 'absolute conversion paths required') + checker.require(raw.is_file() and not raw.is_symlink() and not png.exists() + and not receipt.exists() and raw.resolve() != png.resolve(), 'fresh separate conversion output') + raw_hash = checker.sha(raw) + checker.require(raw_hash == cua_receipt['raw_sha256'] and cua_receipt['tool_call_id'], + 'CUA conversion input hash/call mismatch') + kind, width, height = checker.image_kind(raw.read_bytes()) + checker.require(kind == 'jpeg', 'conversion requires JPEG input') + converter = '/usr/bin/sips' + converter_hash = checker.sha(converter) + argv = [converter, '-s', 'format', 'png', str(raw), '--out', str(png)] + process = subprocess.run(argv, capture_output=True, timeout=900, check=False) + rec = dict(raw_path=str(raw), raw_sha256=raw_hash, raw_kind=kind, + tool_call_id=cua_receipt['tool_call_id'], converter=converter, + converter_sha256=converter_hash, argv=argv, exit_code=process.returncode, + stdout_sha256=hashlib.sha256(process.stdout).hexdigest(), + stderr_sha256=hashlib.sha256(process.stderr).hexdigest(), + png_path=str(png), png_sha256=checker.sha(png) if png.is_file() else None, + width=width, height=height) + # Preserve failure receipts too. These logs allow independent hash checking. + with receipt.open('x') as output: + json.dump(rec, output, indent=2) + output.write('\n') + with Path(str(receipt) + '.stdout').open('xb') as output: + output.write(process.stdout) + with Path(str(receipt) + '.stderr').open('xb') as output: + output.write(process.stderr) + checker.require(checker.sha(raw) == raw_hash, 'raw CUA bytes changed during conversion') + checker.require(checker.sha(converter) == converter_hash, 'converter changed during conversion') + checker.inspect_conversion(rec, cua_receipt, converter_hash) + return rec + +def collect_os(r, root, out, stage, engine, commit, refs, protected, outer): + """Adapted P08-03 collect_os protocol, retaining owned-process guards. + + Root supplies CUA ack/receipt files in the requested directories. Neither + controller nor passive endpoints generate input or change permissions. + """ + r.require(not outer.native.helper['live_engines'](), 'external engine present') + checker = module('p23_os_pins', HERE/'check_p23_life_observation_evidence.py') + before = r.inventory(stage, stage=True) + public = (stage/'project.godot').read_bytes() + (out/'os-pair').mkdir() + phases = [] + for label in ('A-writer', 'A-reader', 'B-writer', 'B-reader'): + layout, role = label.split('-') + pair = out/'os-pair'/layout + pair.mkdir(exist_ok=role == 'reader') + r.require(not outer.native.helper['live_engines'](), 'external engine present') + r.require(r.source_refs(root) == refs and r.default_player(root) == protected, 'OS before guards') + r.require(r.inventory(stage, stage=True) == before, 'OS stage before guard') + r.require(shutil.disk_usage(out).free >= 500*1024*1024, 'OS disk admission') + folder = out/'os-runs'/label + folder.mkdir(parents=True) + name = 'TheTree-PCK-tests-'+uuid.uuid4().hex+'-p23-observation-'+label + profile = r.profile_path(name) + r.require(not profile.exists(), 'fresh OS private profile') + formal_source = pair/'formal' if role == 'reader' else None + formal_before = r.inventory(formal_source) if formal_source else {} + if role == 'reader': + r.require(set(formal_before) == {'world.bin','manifest.json','thumbnail.png','journal.log'}, 'all formal inputs') + formal = profile/'saves/manual_ui' + formal.mkdir(parents=True) + for member, pin in formal_before.items(): + r.copy_source(formal_source/member, formal/member) + r.require(r.sha(formal/member) == pin['sha256'], 'formal fixture copy SHA') + recorder = folder/'passive_os.gd' + r.copy_source(root/RECORDER, recorder) + proof = folder/'teardown' + (proof/'owned-audio').mkdir(parents=True) + env = r.fresh_environment(dict(os.environ)) + env.update(P23_OBSERVATION_ROOT=str(pair), P23_OBSERVATION_ROLE=role, P23_LAYOUT=layout, + THE_TREE_AUDIO_TEARDOWN_PROOF_DIR=str(proof/'owned-audio'), + THE_TREE_AUDIO_FIXTURE_CONTROL_PROOF=str(proof/'control.json')) + command = [engine, '--path', str(stage), '--script', str(recorder)] + base = {'protocol':'p23-observation-native-os-v1', 'role':role, 'layout':layout, 'commit':commit, + 'source_map':{n:v['sha256'] for n,v in refs.items()}, 'stage':str(stage), + 'stage_before':before, 'profile':str(profile), 'profile_name':name, + 'profile_before':{'existed':profile.exists(), 'files':r.inventory(profile) if profile.exists() else {}}, 'formal_input':formal_before, + 'formal_source':str(formal_source) if formal_source else None, 'owner_pid':os.getpid(), + 'default_before':protected, 'command':command, 'engine_sha256':r.sha(engine), + 'controller_sha256':r.sha(root/'tools/run_p23_life_observation.py'), + 'recorder_sha256':r.sha(recorder), 'formal_acceptance':False, + 'timeout_seconds':OS_CHILD_TIMEOUT_SECONDS, + 'inputs':checker.input_map(root), 'spec_sha256':checker.SPEC_SHA, + 'settings_sha256':hashlib.sha256(public).hexdigest(), + 'environment':{k:v for k,v in env.items() if k.startswith(('P23_', 'THE_TREE_'))}, + 'environment_sha256':hashlib.sha256(json.dumps(env,sort_keys=True).encode()).hexdigest()} + r.write(folder/'started.json', base) + error = None; children = []; guards = []; exit_code = None; owner_proof = None + try: + (stage/'project.godot').write_bytes(r.private_project(public, name)) + with (folder/'engine-native.log').open('x') as log: + process = subprocess.Popen(command, cwd=stage, env=env, stdout=log, stderr=subprocess.STDOUT) + children.append(process) + r.write(folder/'process-started.json', dict(base, actual_pid=process.pid, + monotonic_ns=time.monotonic_ns(), state='waiting_for_root_cua_requests')) + print(json.dumps({'actual_pid':process.pid, 'request_directory':str(pair), + 'state':'waiting_for_root_cua', 'role':role}), flush=True) + exit_code = process.wait(timeout=base['timeout_seconds']) + r.require(exit_code == 0, 'ordinary native OS exit0') + raw = (folder/'engine-native.log').read_text() + r.require('ERROR:' not in raw and 'SCRIPT ERROR:' not in raw, 'OS native diagnostics') + matches = re.findall(r'(?m)^P23 passive OS observation: ([0-9]+) checks, 0 failures$', raw) + r.require(len(matches) == 1 and int(matches[0]) > 0, 'passive observer completion') + observed = r.read(pair/(role+'-profile.json')) + r.require(observed['pid'] == process.pid and Path(observed['user_data_dir']).resolve() == profile.resolve() + and observed['display'] != 'headless', 'actual OS private profile/display') + collect_screenshots(out, pair, role) + owners = r.module('p23_os_owner_gate', r.SUPPORT/'ordinary_proof_gate.py') + owner_proof = owners.ordinary_gate(proof, process.pid, folder, ['single']) + except BaseException as caught: + error = type(caught).__name__+': '+str(caught) + finally: + try: + cleanup, cleanup_errors = outer.finish_captured(children) + finally: + (stage/'project.godot').write_bytes(public) + try: + r.require(r.source_refs(root) == refs, 'OS source unchanged') + r.require(r.inventory(stage, stage=True) == before, 'OS stage unchanged') + r.require(r.default_player(root) == protected, 'OS default player unchanged') + if formal_source: + r.require(r.inventory(formal_source) == formal_before, 'formal input unchanged') + r.require(r.sha(recorder) == base['recorder_sha256'], 'recorder unchanged') + except BaseException as caught: + guards.append(type(caught).__name__+': '+str(caught)) + passed = not (error or guards or cleanup_errors) and len(children) == 1 and exit_code == 0 + r.write(folder/'ended.json', dict(base, actual_pid=children[0].pid if children else None, + exit_code=exit_code, engine_ended=bool(cleanup) and all(v['engine_ended'] for v in cleanup), + error=error, guard_errors=guards, cleanup_errors=cleanup_errors, owned_cleanup=cleanup, + ordinary_owner_proof=owner_proof, stage_after=r.inventory(stage,stage=True), + engine_sha256_after=r.sha(engine), inputs_after=checker.input_map(root), + settings_sha256_after=hashlib.sha256((stage/'project.godot').read_bytes()).hexdigest(), + environment_after={k:v for k,v in env.items() if k.startswith(('P23_', 'THE_TREE_'))}, + environment_sha256_after=hashlib.sha256(json.dumps(env,sort_keys=True).encode()).hexdigest(), + default_after=r.default_player(root), source_after=r.source_refs(root), + profile_after=r.inventory(profile), passed=passed)) + phases.append('os-runs/'+label+'/ended.json') + r.require(passed, 'native OS collection failed: '+str(error or guards or cleanup_errors)) + return phases + +def module(name, path): + spec = importlib.util.spec_from_file_location(name, path) + obj = importlib.util.module_from_spec(spec) + spec.loader.exec_module(obj) + return obj + +def absolute(value): + path = Path(value) + if not path.is_absolute(): + raise argparse.ArgumentTypeError('absolute path required') + return path.resolve() + +def collect(args): + root, out = args.project_root, args.output + if out.is_relative_to(root): + raise ValueError('evidence output must be outside the frozen source checkout') + r = module('p23_standard_runner', root/'tools/run_all_tests.py') + commit = subprocess.check_output(['git', '-C', str(root), 'rev-parse', 'HEAD'], text=True).strip() + r.require(commit == args.commit, 'actual final submitted commit required') + r.require(not subprocess.check_output(['git','-C',str(root),'status','--porcelain'], text=True), 'frozen clean worktree required') + r.require(not out.exists(), 'fresh output; never overwrite earlier proof') + refs = r.source_refs(root) + checker = module('p23_initial_inputs', root/'tools/check_p23_life_observation_evidence.py') + inputs_before = checker.input_map(root) + outer = r.module('p23_owned_outer', r.SUPPORT/'owned_outer.py') + r.require(not outer.native.helper['live_engines'](), 'one native engine') + engine = shutil.which(args.godot) + r.require(engine is not None, 'native engine exists') + engine = str(Path(engine).resolve()) + out.mkdir(parents=True) + stage = out/'stage'; stage.mkdir() + (out/'starters').mkdir(); (out/'bootstrap').mkdir() + for name, pin in r.read(r.SUPPORT/'bootstrap-scripts.json').items(): + data = pin['text'].encode() + r.require(hashlib.sha256(data).hexdigest() == pin['sha256'], 'bootstrap pin') + (out/'bootstrap'/name).write_bytes(data) + for name, pin in refs.items(): + target = stage/name; target.parent.mkdir(parents=True, exist_ok=True) + r.copy_source(Path(pin['path']), target) + r.require(r.sha(target) == pin['sha256'], 'whole copied source '+name) + expected = {n: {'bytes':v['bytes'], 'sha256':v['sha256']} for n,v in refs.items()} + selected = {'root_before':refs, 'map':{n:v['sha256'] for n,v in refs.items()}, 'timeout':180} + r.require(r.inventory(stage, stage=True) == expected, 'complete private source') + protected = r.default_player(root); public = (stage/'project.godot').read_bytes() + paths = ['res://'+n for n in sorted(refs) if n.endswith('.gd')] + r.write(out/'parse-paths.json', paths) + markers = r.module('p23_strict_markers', root/'tools/strict_test_markers.py') + contracts = markers.load_contracts(root/CONTRACT) + policy = r.read(r.SUPPORT/'suite-policy.json') + diagnostics = r.module('p23_diagnostics', r.SUPPORT/'diagnostic_gate.py') + owners = r.module('p23_owners', r.SUPPORT/'ordinary_proof_gate.py') + audio = r.module('p23_audio', r.SUPPORT/'audio_evidence_gate.py') + r.write(out/'runner-input.json', {'protocol':'p23-normal-observation-v1', 'commit':commit, + 'selected_source_map':selected['map'], 'engine':engine, 'engine_sha256':r.sha(engine), + 'owner_pid':os.getpid(), 'default_player':protected, 'diagnostic_seed':806042, + 'inputs':inputs_before, 'provenance':checker.input_provenance(root), + 'pixel_decoder':{'interpreter':sys.executable, + 'PIL_available':importlib.util.find_spec('PIL') is not None, + 'raw_screenshots':'preserve bytes; Root lossless conversion required for JPEG without PIL'}}) + phases = []; previous = {}; cases = []; error = None; os_proof = None + deadline = time.monotonic()+900 + saved_env = {k:os.environ.get(k) for k in ('P23_LAYOUT','P23_OUTPUT')} + def phase(label, test): + remaining = int(deadline-time.monotonic()) + r.require(remaining >= (180 if label == 'import' else 1), '900s collection deadline') + selected['timeout'] = min(180, remaining) + phase_out = out/'runs'/label + phase_out.mkdir(parents=True) + (phase_out/'starters').mkdir(); (phase_out/'bootstrap').mkdir() + for original in (out/'bootstrap').iterdir(): + r.copy_source(original,phase_out/'bootstrap'/original.name) + r.require(r.sha(original)==r.sha(phase_out/'bootstrap'/original.name),'bootstrap full copy') + r.write(phase_out/'parse-paths.json',paths) + row = r.execute_phase(engine, stage, phase_out, label, test, selected, expected, previous, + policy, contracts, paths, public, protected, root, outer, markers, diagnostics, owners, audio) + # Supplement the existing runner receipt; preserve it verbatim. + r.write(phase_out/'p23-inputs.json', dict(source_map=selected['map'], source_after=r.source_refs(root), + engine_sha256=r.sha(engine), inputs=inputs_before, inputs_after=checker.input_map(root), + spec_sha256=checker.SPEC_SHA, settings_sha256=hashlib.sha256(public).hexdigest(), + stage_before=expected, stage_after=r.inventory(stage,stage=True), + default_before=protected, default_after=r.default_player(root), + receipt_sha256=r.sha(phase_out/(label+'-receipt.json')))) + phases.append('runs/'+label+'/'+label+'-receipt.json') + r.require(not row['failed'], 'strict phase failed: '+label) + try: + phase('import', None); phase('parse', None) + for layout in ('A', 'B'): + directory = out/('layout-'+layout) + os.environ.update(P23_LAYOUT=layout, P23_OUTPUT=str(directory)) + phase('layout-'+layout, 'tests/ui/test_p23_life_observation.gd') + cases.append({'directory':directory.name, 'phase':phases[-1], 'files':r.inventory(directory)}) + if args.input_mode == 'native_os': + os_phases = collect_os(r, root, out, stage, engine, commit, refs, protected, outer) + checker = module('p23_os_input_map', root/'tools/check_p23_life_observation_evidence.py') + os_proof = 'os-proof.json' + r.write(out/os_proof, {'source_map':selected['map'], 'inputs':checker.input_map(root), + 'input_method':'native_os', 'observer_mutations':[], 'phases':os_phases, 'directory':'os-pair'}) + except BaseException as caught: + error = type(caught).__name__+': '+str(caught) + finally: + cleanup, cleanup_errors = outer.finish_captured(outer.OWNED) + for key,value in saved_env.items(): + if value is None: os.environ.pop(key, None) + else: os.environ[key] = value + if r.source_refs(root) != refs or r.default_player(root) != protected: + error = (error+'; ' if error else '')+'protected root/profile changed' + if checker.input_map(root) != inputs_before: + error = (error+'; ' if error else '')+'spec/fixture/controller inputs changed' + r.write(out/'runner-ended.json', {'error':error, 'cleanup':cleanup, 'cleanup_errors':cleanup_errors, + 'phases':phases, 'formal_acceptance':False, 'native_Windows':'pending'}) + checker = module('p23_observation_checker', root/'tools/check_p23_life_observation_evidence.py') + r.write(out/'index.json', {'schema':'p23-life-observation-v1', 'commit':commit, + 'source_map':selected['map'], 'input_map':inputs_before, + 'provenance':checker.input_provenance(root), + 'marker_contract_sha256':r.sha(root/CONTRACT), + 'spec_sha256':hashlib.sha256((root/'docs/spec/acceptance/P23-03.json').read_text().strip().encode()).hexdigest(), + 'cases':cases, 'phases':phases, 'conditions':list(CONDITIONS), + 'artifacts':{str(p.relative_to(out)):r.sha(p) + for p in out.rglob('*') if p.is_file() and 'stage' not in p.relative_to(out).parts}, + 'os_proof':os_proof, 'regression':None, + 'acceptance':'pending', 'collection_error':error}) + return 1 if error or cleanup_errors else 0 + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--project-root', type=absolute, required=True) + parser.add_argument('--output', type=absolute, required=True) + parser.add_argument('--commit', required=True) + parser.add_argument('--godot', default='godot') + parser.add_argument('--input-mode', choices=('handler', 'native_os'), default='handler') + parser.add_argument('--collect', action='store_true', required=True) + return collect(parser.parse_args(argv)) if __name__ == '__main__': raise SystemExit(main()) diff --git a/tools/test_p23_life_observation_evidence.py b/tools/test_p23_life_observation_evidence.py index db46ca6..4c149b4 100644 --- a/tools/test_p23_life_observation_evidence.py +++ b/tools/test_p23_life_observation_evidence.py @@ -3,11 +3,318 @@ import copy import unittest import sys +import contextlib +import io +import json +import hashlib +import struct +import zlib +import tempfile +from pathlib import Path +from unittest import mock sys.dont_write_bytecode = True import check_p23_life_observation_evidence as check +class ImageTests(unittest.TestCase): + def test_collector_conversion_is_bound_to_each_cua_screenshot(self): + runner = check.module('p23_unit_collector', check.ROOT/'tools/run_p23_life_observation.py') + with tempfile.TemporaryDirectory(dir=check.ROOT/'tests/fixtures/p23-03') as temporary: + root = Path(temporary) + pair = root/'os-pair/A' + request_dir = pair/'writer-request-0001' + request_dir.mkdir(parents=True) + request = request_dir/'request.json' + request.write_text(json.dumps(dict(sequence=1, target_pid=123))) + raw = request_dir/'raw.jpg' + raw.write_bytes(b'\xff\xd8unit-only\xff\xd9') + shot = dict(path=str(raw.relative_to(root)), raw_sha256=check.sha(raw), tool_call_id='unit-shot-call') + receipt = request_dir/'cua.json' + receipt.write_text(json.dumps(dict(screenshots=[shot]))) + ack = request_dir/'ack.json' + ack.write_text(json.dumps(dict(request_sha256=check.sha(request), input_receipt=str(receipt.relative_to(root))))) + (pair/'writer-requests.json').write_text(json.dumps(dict(requests=[str(ack.relative_to(root))]))) + def convert(raw_path, png_path, cua, receipt_path): + self.assertEqual(cua, shot) + self.assertEqual(Path(raw_path).read_bytes(), b'\xff\xd8unit-only\xff\xd9') + Path(png_path).write_bytes(self.png()) + Path(receipt_path).write_text('{}') + with mock.patch.object(runner, 'module', return_value=check), \ + mock.patch.object(check, 'image_kind', return_value=('jpeg', 2, 2)), \ + mock.patch.object(runner, 'convert_screenshot', side_effect=convert) as converter: + output = runner.collect_screenshots(root, pair, 'writer') + converter.assert_called_once() + rows = json.loads((root/output).read_text()) + self.assertEqual(rows[0]['tool_call_id'], shot['tool_call_id']) + self.assertEqual(rows[0]['raw_sha256'], check.sha(raw)) + self.assertEqual(rows[0]['png_sha256'], check.sha(root/rows[0]['png_path'])) + self.assertTrue(rows[0]['conversion']) + + @staticmethod + def png(mode=0, channels=3): + def chunk(kind, body): + return struct.pack('>I', len(body)) + kind + body + struct.pack('>I', zlib.crc32(kind + body)) + rows = [bytes([10, 20, 30, 40][:channels] * 2), + bytes([50, 60, 70, 80][:channels] * 2)] + filtered = bytearray() + prior = bytes(2 * channels) + for row in rows: + filtered.append(mode) + for x, value in enumerate(row): + left, up, corner = (row[x-channels] if x >= channels else 0, + prior[x], prior[x-channels] if x >= channels else 0) + p = left + up - corner + distances = [abs(p-left), abs(p-up), abs(p-corner)] + predictors = [0, left, up, (left+up)//2, + [left, up, corner][distances.index(min(distances))]] + filtered.append((value-predictors[mode]) % 256) + prior = row + header = struct.pack('>IIBBBBB', 2, 2, 8, 2 if channels == 3 else 6, 0, 0, 0) + return (b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', header) + + chunk(b'IDAT', zlib.compress(filtered)) + chunk(b'IEND', b'')) + + def test_all_png_filters_rgb_and_rgba(self): + for mode in range(5): + for channels in (3, 4): + with self.subTest(mode=mode, channels=channels): + w, h, rows = check.png_pixels(self.png(mode, channels)) + self.assertEqual((w, h), (2, 2)) + self.assertEqual(rows[0][0], tuple([10, 20, 30, 40][:channels])) + self.assertEqual(rows[1][1], tuple([50, 60, 70, 80][:channels])) + + def test_crc_and_trailing_bytes_rejected(self): + data = bytearray(self.png()) + data[29] ^= 1 + with self.assertRaisesRegex(ValueError, 'CRC mismatch'): + check.png_pixels(bytes(data)) + with self.assertRaisesRegex(ValueError, 'invalid IEND'): + check.png_pixels(self.png() + b'extra') + + def conversion(self): + raw, png = b'\xff\xd8synthetic-unit-test\xff\xd9', self.png() + digest = lambda b: hashlib.sha256(b).hexdigest() + rec = dict(raw_path='/raw.png', raw_sha256=digest(raw), raw_kind='jpeg', + tool_call_id='unit-test-call', converter='/usr/bin/sips', converter_sha256='a'*64, + argv=['/usr/bin/sips', '-s', 'format', 'png', '/raw.png', '--out', '/decoded.png'], + exit_code=0, stdout_sha256=digest(b''), stderr_sha256=digest(b''), + png_path='/decoded.png', png_sha256=digest(png), width=2, height=2) + cua = dict(raw_sha256=digest(raw), tool_call_id='unit-test-call') + return rec, cua, {'/raw.png': raw, '/decoded.png': png} + + def test_conversion_chain_and_specific_forgery_deficits(self): + rec, cua, files = self.conversion() + helper = mock.Mock() + helper.jpeg_dimensions.return_value = (2, 2) + with mock.patch.object(Path, 'read_bytes', lambda p: files[str(p)]), \ + mock.patch.object(check, 'module', return_value=helper): + self.assertEqual(check.inspect_conversion(rec, cua, 'a'*64)[:2], (2, 2)) + for key, value, deficit in [('converter_sha256', 'b'*64, 'converter SHA mismatch'), + ('argv', [], 'converter argv mismatch'), + ('raw_sha256', 'b'*64, 'CUA raw hash/call mismatch'), + ('width', 3, 'conversion dimensions mismatch')]: + with self.subTest(key=key): + bad = dict(rec, **{key: value}) + with self.assertRaisesRegex(ValueError, deficit): + check.inspect_conversion(bad, cua, 'a'*64) + with self.assertRaisesRegex(ValueError, 'JPEG screenshot requires conversion record'): + check.screenshot_pixels('/raw.png') + + +class RestAuditTests(unittest.TestCase): + def fixture(self): + member = dict(staff_id='s', state='OFF_DUTY', health=100, current_task_id='', current_cell=[0, 0, 0]) + facilities = [dict(instance_id=fid, definition_id='staff_waiting_room', origin=[0, 0, 0], + occupied_cells=[], state='OPEN', suspension_reason='') for fid in ('a', 'b')] + runtime = dict(staff=dict(staff=[member]), facilities=dict(instances=facilities), + defense=dict(raids=[]), events=dict(actor_locks={}), elevators=dict(trips=[]), + path=dict(cell_traffic=[dict(cell=[x, 0, 0]) for x in (-1, 0, 1)]), + world=dict(bounds=dict(min_x=-1, max_x=1, min_y=0, max_y=0), occupancies=[])) + routes = [dict(cell=[x, 0, 0], path=[[0, 0, 0], [x, 0, 0]] if x else [[0, 0, 0]], + edges=[dict(**{'from': [0, 0, 0], 'to': [x, 0, 0]}, total_cost=0)] if x else [], + calculate_path_cost=0) for x in (-1, 0, 1)] + observed = [] + for f in facilities: + observed.append({k: f[k] for k in ('instance_id', 'definition_id', 'origin', 'occupied_cells')}) + observed[-1].update(operation_state='OPERATING', probes=[dict(cell=[x, 0, 0], has_cell=True) for x in (0, -1, 1)], + ordered=[[x, 0, 0] for x in (-1, 0, 1)], routes=copy.deepcopy(routes)) + selected = dict(reachable=True, resting=True, cell=[-1, 0, 0], facility_id='a', label='待機室で休憩中') + audit = dict(path_service_sha256=check.sha(check.ROOT/'scripts/world/path_service.gd'), + staff=[dict(member, defense_memberships=[], actor_lock=False, in_transit=False, + facilities=observed, comparison_only=selected)]) + report = dict(resting_staff={'s': 'a'}, actor_drawings=[], layer=0, carried_patients={}, rooms={}, + viewport=[1440, 810], zoom=1, pan=[0, 0]) + return dict(systems=dict(runtime=runtime)), report, audit + + def test_selection_ties_and_nonmutation(self): + state, report, audit = self.fixture() + before = copy.deepcopy((state, report, audit)) + self.assertEqual(check.compare_rest(state, report, audit)['s']['cell'], [-1, 0, 0]) + self.assertEqual((state, report, audit), before) + + def test_rest_forgeries(self): + mutations = { + 'selection': lambda a: a['staff'][0]['comparison_only'].update(facility_id='b'), + 'tie': lambda a: a['staff'][0]['comparison_only'].update(cell=[0, 0, 0]), + 'lock': lambda a: a['staff'][0].update(actor_lock=True), + 'transit': lambda a: a['staff'][0].update(in_transit=True), + 'label': lambda a: a['staff'][0]['comparison_only'].update(label='宿舎で休憩中'), + 'cost': lambda a: a['staff'][0]['facilities'][0]['routes'][0]['edges'][0].update(total_cost=9), + 'facility_order': lambda a: a['staff'][0]['facilities'].reverse(), + 'candidate_order': lambda a: a['staff'][0]['facilities'][0]['ordered'].reverse(), + 'empty_edge': lambda a: a['staff'][0]['facilities'][0]['routes'][0]['edges'][0].clear(), + } + for name, mutate in mutations.items(): + with self.subTest(name=name): + state, report, audit = self.fixture() + mutate(audit) + with self.assertRaises(ValueError): + check.compare_rest(state, report, audit) + + def test_ineligible_and_transit_recomputed(self): + state, report, audit = self.fixture() + state['systems']['runtime']['events']['actor_locks']['s'] = 'event' + audit['staff'][0].update(actor_lock=True, comparison_only={}) + report['resting_staff'] = {} + self.assertEqual(check.compare_rest(state, report, audit), {'s': {}}) + state, report, audit = self.fixture() + state['systems']['runtime']['elevators']['trips'] = [dict(actor_id='staff:s')] + audit['staff'][0]['in_transit'] = True + audit['staff'][0]['comparison_only'].update(resting=False, label='休憩先へ移動中') + report['resting_staff'] = {} + self.assertFalse(check.compare_rest(state, report, audit)['s']['resting']) + + +class InfrastructureTests(unittest.TestCase): + def test_matrix_requires_each_condition_and_each_category(self): + executed = {c: {k: set(v) for k, v in categories.items()} for c, categories in check.REQUIRED_CHECKS.items()} + self.assertTrue(all(r['status'] == 'passed' for r in check.condition_matrix(executed))) + for condition in check.CONDITIONS: + for category in check.CATEGORIES: + with self.subTest(condition=condition, category=category): + broken = copy.deepcopy(executed) + broken[condition][category].clear() + matrix = check.condition_matrix(broken) + self.assertEqual(next(r for r in matrix if r['condition'] == condition)['status'], 'pending') + self.assertIn(category, check.BLOCKERS(broken)[condition]) + + def test_distinct_pid_ack_and_formal_hash_negatives(self): + members = {name: 'a'*64 for name in ('world.bin', 'manifest.json', 'thumbnail.png', 'journal.log')} + writer = dict(actual_pid=1) + reader = dict(actual_pid=2, formal_input={k: dict(sha256=v) for k, v in members.items()}) + saved = dict(pid=1, members=members) + records = [dict(request=dict(action='load_menu', target_pid=2, sequence=1, requested_ticks_usec=10, requested_unix_ns=90), + ack=dict(target_pid=2, sequence=1, request_sha256='b'*64, timestamp_ns=110), + observed=dict(pid=2, sequence=1, request_sha256='b'*64, ack_observed_ticks_usec=20, ack_observed_unix_ns=120, status='手動セーブを読み込みました。'), + cua=dict(target_pid=2, method='native_os', tool_call_id='actual-call', timestamp_ns=100), + request_sha256='b'*64)] + check.inspect_resume(writer, reader, saved, records, members) + for change in ('pid', 'ack', 'time', 'hash', 'id'): + with self.subTest(change=change): + r, q = copy.deepcopy(reader), copy.deepcopy(records) + if change == 'pid': r['actual_pid'] = 1 + if change == 'ack': q.clear() + if change == 'time': q[0]['observed']['ack_observed_ticks_usec'] = 9 + if change == 'hash': r['formal_input']['world.bin']['sha256'] = 'c'*64 + if change == 'id': q[0]['ack']['sequence'] = 2 + with self.assertRaises(ValueError): check.inspect_resume(writer, r, saved, q, members) + + def test_rectangle_pixel_and_transform_negatives(self): + source = 'scripts/ui/pixel_people.gd' + colors = list(dict.fromkeys(check.re.findall(r'Color\("([0-9a-fA-F]{6})"', (check.ROOT/source).read_text()))) + report = dict(rooms={}, landing_badges=[], cabins=[], resting_staff={}, badges=[], + actor_drawings=[dict(key='staff/s', staff=True, at=[1, 1], scale=1)]) + row = dict(category='staff', id='staff/s', rect=[1, 1, 16, 18], viewport_rect=[1, 1, 16, 18], + screen_rect=[1, 1, 16, 18], style_source=source, style_colors=colors) + audit = dict(rows=[row]) + matrix = [1, 0, 0, 1, 0, 0] + image = (24, 24, [[(113, 157, 148)]*24 for _ in range(24)]) + self.assertEqual(check.compare_rectangles(report, audit, matrix, matrix, matrix, image), ['staff/staff/s']) + for change in ('rectangle', 'pixels', 'transform', 'coverage', 'palette'): + with self.subTest(change=change): + a, m, img = copy.deepcopy(audit), matrix[:], image + if change == 'rectangle': a['rows'][0]['rect'][0] += 1 + if change == 'pixels': img = (24, 24, [[(0, 0, 0)]*24 for _ in range(24)]) + if change == 'transform': m[4] = 10 + if change == 'coverage': a['rows'].clear() + if change == 'palette': a['rows'][0]['style_colors'] = ['000000'] + with self.assertRaises(ValueError): check.compare_rectangles(report, a, matrix, m, matrix, img) + + def test_interpolation_history_tampering(self): + state = dict(systems=dict(runtime=dict(guests=dict(guests=[]), staff=dict(staff=[]), + elevators=dict(shafts=[], trips=[], current_minute=0)))) + report = dict(animation_time=0.0, paused=True, layer=0, zoom=1, pan=[0,0], viewport=[100,100], + actor_tracks={}, elevator_tracks={}, actor_drawings=[], cabins=[]) + audit = dict(frames=[dict(frame=1, delta=.01, scaled_delta=0, display=copy.deepcopy(report)), + dict(frame=2, delta=.01, scaled_delta=0, display=copy.deepcopy(report))]) + check.compare_motion(state, report, audit) + for change in ('delta', 'frame', 'display'): + with self.subTest(change=change): + bad = copy.deepcopy(audit) + if change == 'delta': bad['frames'][1]['scaled_delta'] = 1 + if change == 'frame': bad['frames'][1]['frame'] = 1 + if change == 'display': bad['frames'][1]['display']['animation_time'] = 1 + with self.assertRaises(ValueError): check.compare_motion(state, report, bad) + + def test_verify_only_missing_index_is_a_concrete_deficit(self): + with mock.patch.dict(check.os.environ, {'BUILD_OUTPUT_DIR': '/missing-p23-proof'}, clear=True): + with contextlib.redirect_stdout(io.StringIO()) as output: + self.assertEqual(check.main(['--verify-only']), 1) + result = json.loads(output.getvalue()) + self.assertEqual(result['integrity'], 'fail') + self.assertEqual(result['acceptance'], 'pending') + self.assertIn('index.json', result['deficits'][0]) + self.assertNotIn('blocked', result['deficits'][0]) + + def test_verify_only_requires_build_output(self): + with mock.patch.dict(check.os.environ, {}, clear=True): + with contextlib.redirect_stderr(io.StringIO()) as output: + self.assertEqual(check.main(['--verify-only']), 2) + self.assertIn('BUILD_OUTPUT_DIR is required', output.getvalue()) + + def test_normative_plan_change_invalidates_but_progress_does_not(self): + root = check.ROOT + text = (root / check.PLAN).read_text() + original = check.input_map(root) + progress = text.splitlines(keepends=True) + progress[2] = 'Progress-only test mutation\n' + with mock.patch.object(Path, 'read_text', return_value=''.join(progress)): + self.assertEqual(check.input_map(root), original) + changed = text.replace('同需要の動線改善を観察できる。', 'altered normative acceptance') + self.assertNotEqual(changed, text) + with mock.patch.object(Path, 'read_text', return_value=changed): + self.assertNotEqual(check.input_map(root), original) + self.assertNotIn(check.PLAN, original) + self.assertIn('full_plan_sha256', check.input_provenance(root)) + for path in (root / 'tests/fixtures/p23-03').rglob('*'): + if path.is_file(): + self.assertIn(path.relative_to(root).as_posix(), original) + + def test_os_process_guards_reject_engine_profile_and_source_drift(self): + source = {'source.gd': 'a' * 64} + receipt = dict(engine_ended=True, passed=True, exit_code=0, error=None, + guard_errors=[], cleanup_errors=[], default_before={}, default_after={}, + stage_before={}, stage_after={}, source_map=source, + source_after={'source.gd': {'sha256': 'a' * 64}}, + owned_cleanup=[{'engine_ended': True}], ordinary_owner_proof={'present': True}) + receipt.update(spec_sha256=check.SPEC_SHA, inputs={'spec': 'a'*64}, inputs_after={'spec': 'a'*64}, + environment={'P23_LAYOUT': 'A'}, environment_after={'P23_LAYOUT': 'A'}, actual_pid=123, + profile='/private/test', profile_name='test', profile_before={'existed': False, 'files': {}}, profile_after={}) + for field in ('engine_sha256', 'settings_sha256', 'environment_sha256'): + receipt[field] = receipt[field+'_after'] = 'a'*64 + check.inspect_os_receipt(receipt, source) + for field, value in [('engine_ended', False), ('default_after', {'changed': True}), + ('source_after', {'source.gd': {'sha256': 'b' * 64}}), + ('stage_after', {'changed': True}), ('owned_cleanup', []), + ('environment_after', {}), ('profile_before', {}), + ('engine_sha256_after', 'b'*64), ('spec_sha256', 'b'*64)]: + with self.subTest(field=field): + altered = copy.deepcopy(receipt) + altered[field] = value + with self.assertRaises(ValueError): + check.inspect_os_receipt(altered, source) + + class SemanticTests(unittest.TestCase): def setUp(self): self.guest = dict(guest_id='g', state='QUEUING', current_cell=[1, 0, 0],