Collect repeatable P1 workflow evidence

This commit is contained in:
Emil
2026-09-24 02:44:51 +03:00
parent 93946c1d24
commit a68fb8e7fd
2 changed files with 503 additions and 32 deletions
+80
View File
@@ -0,0 +1,80 @@
"""Contracts for reproducible, honestly labelled P1 workflow measurements."""
import importlib.util
from pathlib import Path
import unittest
MODULE = Path(__file__).resolve().parents[1] / "tools/measure_workflows.py"
SPEC = importlib.util.spec_from_file_location("measure_workflows", MODULE)
workflows = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(workflows)
def report(samples):
return {
"format": "faset.workflow-measurements",
"version": 2,
"revision": "0123456789abcdef",
"source_hashes": {"Scripts/Gameplay.cpp": "a" * 64},
"build_configuration": "Debug",
"samples": samples,
}
class WorkflowReportTests(unittest.TestCase):
def test_nearest_rank_summary(self):
self.assertEqual(workflows.summarize([1, 2, 3, 4, 5]),
{"median": 3, "p95": 5})
def test_summary_rejects_empty_and_invalid_samples(self):
for values in ([], [0, -1], [1, float("nan")]):
with self.subTest(values=values), self.assertRaises(ValueError):
workflows.summarize(values)
def test_report_requires_revision_hashes_and_repetitions(self):
samples = {"warm_unchanged_build": [
{"seconds": 1.0, "build_configuration": "Debug", "source_hash": "a" * 64}
for _ in range(5)]}
workflows.validate_report(report(samples))
for mutation in (
lambda value: value.update(revision=""),
lambda value: value.update(source_hashes={}),
lambda value: value["samples"]["warm_unchanged_build"].pop(),
lambda value: value["samples"]["warm_unchanged_build"][0].update(
build_configuration="Release"),
):
value = report({"warm_unchanged_build": [dict(sample) for sample in samples["warm_unchanged_build"]]})
mutation(value)
with self.subTest(value=value), self.assertRaises(ValueError):
workflows.validate_report(value)
def test_windowed_claim_rejects_offscreen_profile(self):
value = report({"windowed_first_presented_frame": [{
"seconds": 0.1, "build_configuration": "Debug",
"source_hash": "a" * 64, "presentation_mode": "offscreen"}]})
with self.assertRaises(ValueError):
workflows.validate_report(value)
def test_generated_scene_is_seeded_and_preserves_source(self):
scene = {"format": "faset.scene", "version": 1, "entities": [{
"id": "reference", "name": "Reference", "parent": None,
"components": [{"id": "transform", "type": "faset.transform", "version": 1,
"fields": {"position": [0, 0, 0]}}]}]}
first = workflows.generate_scene_fixture(scene, seed=17, count=8)
second = workflows.generate_scene_fixture(scene, seed=17, count=8)
self.assertEqual(first, second)
self.assertEqual(len(first["entities"]), 9)
self.assertEqual(scene["entities"][0]["id"], "reference")
self.assertEqual(len({entry["id"] for entry in first["entities"]}), 9)
self.assertNotEqual(first, workflows.generate_scene_fixture(scene, seed=18, count=8))
def test_gnu_time_keeps_rss_after_expected_command_failure(self):
self.assertEqual(workflows.parse_peak_rss(
'Command exited with non-zero status 1\n{"peak_rss_kib":353272}\n'), 353272)
with self.assertRaises(ValueError):
workflows.parse_peak_rss("Command exited with non-zero status 1\n")
if __name__ == "__main__":
unittest.main()
+423 -32
View File
@@ -5,32 +5,173 @@ Numbers are observations, not CI pass/fail thresholds. Linux peak RSS comes from
GNU time for the command and its waited-for children; other platforms report null.
"""
import argparse
import copy
import datetime
import hashlib
import json
import math
import os
from pathlib import Path
import platform
import queue
import random
import shutil
import statistics
import subprocess
import threading
import time
def summarize(values):
"""Median and nearest-rank p95 of positive, finite observations."""
if not values or any(not isinstance(value, (int, float)) or
not math.isfinite(value) or value <= 0 for value in values):
raise ValueError("A distribution requires positive finite samples")
ordered = sorted(values)
return {"median": statistics.median(ordered),
"p95": ordered[math.ceil(.95 * len(ordered)) - 1]}
def parse_peak_rss(text):
# GNU time prefixes its -o output with a status sentence when the measured
# command exits nonzero. The JSON format is the final nonempty line.
for line in reversed(text.splitlines()):
try:
value = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(value, dict) and isinstance(value.get("peak_rss_kib"), int):
return value["peak_rss_kib"]
raise ValueError("GNU time did not produce a peak RSS record")
def validate_report(report):
if report.get("format") != "faset.workflow-measurements" or report.get("version") != 2:
raise ValueError("Expected a version 2 Faset workflow report")
if not isinstance(report.get("revision"), str) or not report["revision"].strip():
raise ValueError("A source revision is required")
hashes = report.get("source_hashes")
if not isinstance(hashes, dict) or not hashes or any(
not isinstance(value, str) or len(value) != 64 or
any(character not in "0123456789abcdef" for character in value)
for value in hashes.values()):
raise ValueError("Exact input SHA-256 hashes are required")
configuration = report.get("build_configuration")
if configuration not in {"Debug", "Release", "RelWithDebInfo"}:
raise ValueError("Build configuration is missing or unknown")
samples = report.get("samples")
if not isinstance(samples, dict) or not samples:
raise ValueError("Raw labelled samples are required")
for label, rows in samples.items():
if not isinstance(rows, list) or not rows:
raise ValueError(f"{label} has no raw samples")
if (label.startswith("warm_") or label.startswith("changed_")) and len(rows) < 5:
raise ValueError(f"{label} requires at least five measured repetitions")
for row in rows:
if row.get("build_configuration") != configuration:
raise ValueError(f"{label} mixes build configurations")
if row.get("source_hash") not in hashes.values():
raise ValueError(f"{label} lacks a declared input source hash")
summarize([row.get("seconds")])
if label == "windowed_first_presented_frame" and \
row.get("presentation_mode") != "windowed":
raise ValueError("An offscreen frame cannot be labelled windowed presentation")
if label == "offscreen_first_rendered_frame" and \
row.get("presentation_mode") != "offscreen":
raise ValueError("A windowed frame cannot be labelled offscreen")
def generate_scene_fixture(scene, *, seed, count):
"""Add deterministic visible objects without mutating the checked-in scene."""
if not isinstance(seed, int) or count < 0 or not scene.get("entities"):
raise ValueError("A seed, nonnegative count, and source entity are required")
generated = copy.deepcopy(scene)
rng = random.Random(seed)
reference = next((entry for entry in scene["entities"] if any(
component.get("type") in {"faset.mesh", "faset.sprite"}
for component in entry.get("components", []))), scene["entities"][0])
for index in range(count):
entity = copy.deepcopy(reference)
entity["id"] = f"measured-{seed}-{index:05d}"
entity["name"] = f"Measured object {index + 1}"
entity["parent"] = None
entity["components"] = [component for component in entity.get("components", [])
if component.get("type") in {"faset.transform", "faset.mesh",
"faset.sprite"}]
for component in entity["components"]:
component["id"] = f"{entity['id']}-{component['type']}"
fields = component.get("fields", {})
if component["type"] == "faset.transform":
position = list(fields.get("position", [0, 0, 0]))
position[0] = ((index % 32) - 16) * .45 + rng.uniform(-.05, .05)
position[2 if scene.get("dimension", 3) == 3 else 1] = \
(index // 32) * .45 + rng.uniform(-.05, .05)
fields["position"] = position
fields["scale"] = [.3, .3, .3]
elif component["type"] == "faset.sprite":
fields["size"] = [.3, .3]
generated["entities"].append(entity)
return generated
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--editor", type=Path, required=True)
parser.add_argument("--project", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--lua-project", type=Path,
default=Path(__file__).resolve().parents[1] / "examples/lua")
parser.add_argument("--ui-latency-binary", type=Path)
args = parser.parse_args()
editor, source, output = args.editor.resolve(), args.project.resolve(), args.output.resolve()
lua_source = args.lua_project.resolve()
ui_binary = (args.ui_latency_binary or
editor.parent / ("faset_editor_ui_latency.exe" if os.name == "nt" else
"faset_editor_ui_latency")).resolve()
for file in (editor, ui_binary, source / "project.faset.json",
lua_source / "project.faset.json"):
if not file.is_file():
raise RuntimeError(f"Required measurement input is missing: {file}")
if output.exists():
raise RuntimeError("Use a new output directory; existing evidence is never overwritten")
output.mkdir(parents=True)
project = output / "project"
shutil.copytree(source, project, ignore=shutil.ignore_patterns(".faset", "Exports", "*.blend1"))
samples = []
lua_project = output / "lua-project"
shutil.copytree(lua_source, lua_project,
ignore=shutil.ignore_patterns(".faset", "Exports", "*.blend1"))
raw = output / "raw"
raw.mkdir()
samples = {}
source_hashes = {}
counters = {}
def run(label, arguments, timeout=1800):
memory = output / (label + "-memory.json")
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def remember(path, variant, base=project):
key = f"{base.name}/{path.relative_to(base).as_posix()}#{variant}"
value = digest(path)
source_hashes[key] = value
return value
gameplay = project / "Scripts/Gameplay.cpp"
header = project / "Scripts/Gameplay.hpp"
if not gameplay.is_file() or not header.is_file():
raise RuntimeError("The C++ project needs Scripts/Gameplay.cpp and Gameplay.hpp")
baseline_hash = remember(gameplay, "baseline")
project_hashes = {file.relative_to(project).as_posix(): digest(file)
for file in sorted(project.rglob("*")) if file.is_file()}
lua_input = json.loads((lua_project / "project.faset.json").read_text(encoding="utf-8"))
lua_file = lua_project / lua_input["scripting"]["lua"]["scripts"][0]
lua_baseline_hash = remember(lua_file, "baseline", lua_project)
def run(label, arguments, *, source_hash=baseline_hash, timeout=1800,
expected_failure=False, record=True):
index = counters.get(label, 0) + 1
counters[label] = index
stem = f"{label}-{index:02d}"
memory = raw / (stem + "-memory.json")
command = [str(arg) for arg in arguments]
wrapped = command
if platform.system() == "Linux" and Path("/usr/bin/time").is_file():
@@ -39,44 +180,294 @@ def main():
result = subprocess.run(wrapped, cwd=output, capture_output=True, text=True,
encoding="utf-8", timeout=timeout)
elapsed = time.perf_counter() - started
(output / (label + "-stdout.txt")).write_text(result.stdout, encoding="utf-8")
(output / (label + "-stderr.txt")).write_text(result.stderr, encoding="utf-8")
sample = {"name": label, "seconds": elapsed, "returncode": result.returncode,
"peak_rss_kib": json.loads(memory.read_text())["peak_rss_kib"] if memory.exists() else None}
samples.append(sample)
(output / "progress.json").write_text(json.dumps(samples, indent=2), encoding="utf-8")
if result.returncode:
stdout_file, stderr_file = raw / (stem + "-stdout.txt"), raw / (stem + "-stderr.txt")
stdout_file.write_text(result.stdout, encoding="utf-8")
stderr_file.write_text(result.stderr, encoding="utf-8")
sample = {"seconds": elapsed, "returncode": result.returncode,
"build_configuration": "Debug", "source_hash": source_hash,
"command": command, "stdout": str(stdout_file.relative_to(output)),
"stderr": str(stderr_file.relative_to(output)),
"peak_rss_kib": parse_peak_rss(memory.read_text())
if memory.exists() else None}
(raw / (stem + ".json")).write_text(json.dumps(sample, indent=2) + "\n",
encoding="utf-8")
if record:
samples.setdefault(label, []).append(sample)
(output / "progress.json").write_text(json.dumps(samples, indent=2) + "\n",
encoding="utf-8")
if expected_failure and result.returncode == 0:
raise RuntimeError(f"{label} unexpectedly succeeded")
if result.returncode and not expected_failure:
raise RuntimeError(f"{label} failed: {result.stderr[-4000:]} {result.stdout[-2000:]}")
print(f"{label}: {elapsed:.3f}s, peak RSS {sample['peak_rss_kib']} KiB", flush=True)
return result
print(f"{stem}: {elapsed:.3f}s, peak RSS {sample['peak_rss_kib']} KiB", flush=True)
return result, sample
def call(label, name, arguments=None):
result = run(label, [editor, "--project", project, "--command",
json.dumps({"name": name, "arguments": arguments or {}}), "--wait"])
def call(label, name, arguments=None, *, root=project, source_hash=baseline_hash,
record=True):
result, sample = run(label, [editor, "--project", root, "--command",
json.dumps({"name": name, "arguments": arguments or {}}),
"--wait"], source_hash=source_hash, record=record)
document = json.loads(result.stdout)
if "state" in document and document["state"] != "succeeded":
raise RuntimeError(f"{label} job did not succeed: {document}")
return document
if name == "faset_build":
sample["schema_cache_hit"] = document.get("result", {}).get("schema_cache_hit")
sample["generation_reused"] = document.get("result", {}).get("generation_reused")
return document, sample
for index in range(3):
call(f"headless-startup-{index + 1}", "faset_project")
# Separate GUI startup from headless command latency. Includes two frames and
# shutdown, not a claimed first-visible-frame timestamp.
run("editor-gui-two-frames", [editor, "--project", project, "--frames", "2"])
call("headless_editor_command_startup", "faset_project")
# This is window creation plus two frames and shutdown, not first-present latency.
run("windowed_editor_two_frames", [editor, "--project", project, "--frames", "2"])
manifest = project / "Assets/exit-arch/manifest.json"
if manifest.exists():
call("import-empty-cache", "faset_import", {"path": "Assets/exit-arch/manifest.json"})
call("import-existing-generation", "faset_import", {"path": "Assets/exit-arch/manifest.json"})
call("initial-debug-build", "faset_build")
call("unchanged-debug-build", "faset_build")
gameplay = project / "Scripts/Gameplay.cpp"
original = gameplay.read_bytes()
gameplay.write_bytes(original + b"\n// Measurement: one gameplay translation unit changed.\n")
# Verify the user-facing stale status before a successful replacement.
assert call("schema-stale-after-edit", "faset_schema_status")["stale"]
iteration_started = time.perf_counter()
built = call("changed-debug-build", "faset_build")["result"]
profile = output / "development-player.json"
call("cold_asset_import", "faset_import", {"path": "Assets/exit-arch/manifest.json"})
call("cached_asset_import", "faset_import", {"path": "Assets/exit-arch/manifest.json"})
cold, _ = call("cold_configure_build", "faset_build")
call("warmup_unchanged_build", "faset_build", record=False)
for _ in range(5):
reused, _ = call("warm_unchanged_build", "faset_build")
if not reused["result"].get("schema_cache_hit"):
raise RuntimeError("An unchanged build did not hit the verified schema cache")
original_cpp = gameplay.read_bytes()
original_header = header.read_bytes()
for index in range(6):
gameplay.write_bytes(original_cpp +
f"\n// P1 C++ iteration {index}\n".encode("utf-8"))
source_hash = remember(gameplay, f"cpp-edit-{index}")
stale, _ = call("schema_stale_check", "faset_schema_status",
source_hash=source_hash, record=False)
if not stale["stale"]:
raise RuntimeError("A C++ source edit did not mark gameplay metadata stale")
label = "warmup_changed_cpp_build" if index == 0 else "changed_cpp_build"
call(label, "faset_build", source_hash=source_hash, record=index != 0)
for index in range(6):
header.write_bytes(original_header +
f"\n// P1 header iteration {index}\n".encode("utf-8"))
source_hash = remember(header, f"header-edit-{index}")
label = "warmup_changed_header_build" if index == 0 else "changed_header_build"
call(label, "faset_build", source_hash=source_hash, record=index != 0)
working_header = header.read_bytes()
header.write_bytes(working_header + b"\n#error FASET_P1_EXPECTED_BUILD_FAILURE\n")
failed_hash = remember(header, "intentional-failure")
run("build_failure", [editor, "--project", project, "--command",
json.dumps({"name": "faset_build", "arguments": {}}), "--wait"],
source_hash=failed_hash, expected_failure=True)
header.write_bytes(working_header)
recovery_hash = remember(header, "recovery")
built, _ = call("build_recovery", "faset_build", source_hash=recovery_hash)
settings = json.loads((project / "project.faset.json").read_text(encoding="utf-8"))
scene = project / settings["start_scene"]
player = Path(built["result"]["player"])
player_profiles = []
for index in range(5):
profile = raw / f"cpp-first-frame-{index + 1:02d}.json"
run("player_one_frame_process", [player, "--scene", scene, "--assets",
project / ".faset/cache", "--headless", "--frames",
"1", "--profile", profile],
source_hash=recovery_hash)
profile_data = json.loads(profile.read_text(encoding="utf-8"))
if profile_data["presentation_mode"] != "offscreen" or \
profile_data["completed_frames"] != 1:
raise RuntimeError("The first-frame profile is not one rendered offscreen frame")
player_profiles.append(profile_data)
samples.setdefault("offscreen_first_rendered_frame", []).append({
"seconds": profile_data["startup_ms"]["main_to_first_frame"] / 1000,
"build_configuration": "Debug", "source_hash": recovery_hash,
"presentation_mode": profile_data["presentation_mode"],
"profile": str(profile.relative_to(output))})
# The UI probe measures synthetic keyboard input through snapshot rendering,
# with no display presentation claim.
ui_profile = raw / "editor-input-latency.json"
run("editor_ui_probe_process", [ui_binary, ui_profile], source_hash=recovery_hash)
ui_data = json.loads(ui_profile.read_text(encoding="utf-8"))
if ui_data["presentation_mode"] != "offscreen" or \
ui_data["measured_frames"] != len(ui_data["samples"]):
raise RuntimeError("UI probe lost raw frames or mislabelled presentation")
samples["editor_input_visible_frame"] = [
{"seconds": row["milliseconds"] / 1000, "build_configuration": "Debug",
"source_hash": recovery_hash, "presentation_mode": "offscreen",
"input": row["gizmo"], "profile": str(ui_profile.relative_to(output))}
for row in ui_data["samples"]]
original_lua = lua_file.read_bytes()
lua_build, _ = call("lua_initial_build", "faset_build", root=lua_project,
source_hash=lua_baseline_hash)
lua_player = Path(lua_build["result"]["player"])
lua_scene = lua_project / lua_input["start_scene"]
control = lua_project / ".faset/measurement-control.json"
process = subprocess.Popen([str(lua_player), "--scene", str(lua_scene), "--project",
str(lua_project), "--control", str(control), "--watch-lua",
"--headless", "--frames", "10000000"], cwd=output,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, encoding="utf-8")
events = queue.Queue()
stderr_lines = []
def collect_lua():
for line in process.stderr:
event = (time.perf_counter(), line)
stderr_lines.append(event)
events.put(event)
reader = threading.Thread(target=collect_lua, daemon=True)
reader.start()
def wait_lua(marker, timeout=20):
deadline = time.perf_counter() + timeout
while time.perf_counter() < deadline:
try:
event = events.get(timeout=min(.2, max(.001, deadline - time.perf_counter())))
except queue.Empty:
if process.poll() is not None:
break
continue
if marker in event[1]:
return event[0]
raise RuntimeError(f"Lua Player did not report {marker}; see raw/lua-reload-stderr.txt")
try:
# Startup settles before the watch/reload timing begins.
wait_lua("Lua", timeout=20)
for index in range(6):
lua_file.write_bytes(original_lua +
f"\n-- P1 reload iteration {index}\n".encode("utf-8"))
started = time.perf_counter()
source_hash = remember(lua_file, f"reload-{index}", lua_project)
finished = wait_lua("Lua reloaded:")
if index:
samples.setdefault("changed_lua_reload", []).append({
"seconds": finished - started, "build_configuration": "Debug",
"source_hash": source_hash, "presentation_mode": "offscreen",
"player_log": "raw/lua-reload-stderr.txt"})
control.parent.mkdir(parents=True, exist_ok=True)
staging = control.with_suffix(".tmp")
staging.write_text(json.dumps({"sequence": 1, "command": "stop"}),
encoding="utf-8")
staging.replace(control)
process.wait(timeout=20)
if process.returncode:
raise RuntimeError("Lua Player failed during reload measurements")
finally:
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=3)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=3)
reader.join(timeout=2)
(raw / "lua-reload-stdout.txt").write_text(process.stdout.read(), encoding="utf-8")
(raw / "lua-reload-stderr.txt").write_text("".join(line for _, line in stderr_lines),
encoding="utf-8")
lua_file.write_bytes(original_lua)
lifecycle_profile = raw / "cpp-3000-frame-lifecycle.json"
run("resource_lifecycle_process", [player, "--scene", scene, "--assets",
project / ".faset/cache", "--headless", "--frames",
"3000", "--profile", lifecycle_profile],
source_hash=recovery_hash)
lifecycle = json.loads(lifecycle_profile.read_text(encoding="utf-8"))
frames = lifecycle["samples"]
if len(frames) != 3000:
raise RuntimeError("Resource lifecycle did not complete 3,000 frames")
memory_start = max(row["gpu_allocated_bytes"] for row in frames[100:200])
memory_end = max(row["gpu_allocated_bytes"] for row in frames[-100:])
resource_growth = {"reference_frames": [101, 200], "end_frames": [2901, 3000],
"explicit_gpu_bytes_reference_max": memory_start,
"explicit_gpu_bytes_end_max": memory_end,
"explicit_gpu_bytes_growth": memory_end - memory_start,
"texture_count_reference_max": max(row["texture_count"]
for row in frames[100:200]),
"texture_count_end_max": max(row["texture_count"]
for row in frames[-100:])}
generated_scene = generate_scene_fixture(json.loads(scene.read_text(encoding="utf-8")),
seed=20260924, count=512)
large_scene = raw / "generated-512.scene.json"
large_scene.write_text(json.dumps(generated_scene, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8")
large_hash = remember(large_scene, "generated-512", output)
large_profile = raw / "generated-512-profile.json"
run("larger_generated_scene_process", [player, "--scene", large_scene, "--assets",
project / ".faset/cache", "--headless", "--frames",
"240", "--profile", large_profile],
source_hash=large_hash)
large_data = json.loads(large_profile.read_text(encoding="utf-8"))
engine = Path(__file__).resolve().parents[1]
revision = subprocess.run(["git", "rev-parse", "HEAD"], cwd=engine,
capture_output=True, text=True, check=True).stdout.strip()
dirty = subprocess.run(["git", "status", "--porcelain"], cwd=engine,
capture_output=True, text=True, check=True).stdout.strip()
cpu = platform.processor()
if Path("/proc/cpuinfo").exists():
cpu = next((line.split(":", 1)[1].strip() for line in
Path("/proc/cpuinfo").read_text().splitlines()
if line.startswith("model name")), cpu)
ram_kib = None
if Path("/proc/meminfo").exists():
ram_kib = next((int(line.split()[1]) for line in
Path("/proc/meminfo").read_text().splitlines()
if line.startswith("MemTotal:")), None)
def version(arguments):
try:
return subprocess.run(arguments, capture_output=True, text=True, timeout=15,
encoding="utf-8").stdout.splitlines()[0]
except (OSError, subprocess.TimeoutExpired, IndexError):
return None
report = {
"format": "faset.workflow-measurements", "version": 2,
"recorded_at_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"revision": revision, "working_tree_dirty": bool(dirty),
"source_hashes": source_hashes, "project_hashes_at_copy": project_hashes,
"build_configuration": "Debug", "source_project": str(source),
"lua_source_project": str(lua_source), "editor": str(editor),
"host": {"platform": platform.platform(), "cpu": cpu, "ram_kib": ram_kib,
"logical_cpus": os.cpu_count(), "python": platform.python_version()},
"toolchain": {"cmake": version(["cmake", "--version"]),
"cxx": version(["clang++", "--version"]),
"slang": version(["slangc", "-version"])},
"gpu": {"player_device": player_profiles[0]["device"],
"ui_device": ui_data["device"],
"driver": version(["nvidia-smi", "--query-gpu=driver_version",
"--format=csv,noheader"])},
"settings": {"resolution": [player_profiles[0]["width"],
player_profiles[0]["height"]],
"validation_enabled": player_profiles[0]["validation_enabled"],
"readback_enabled": True, "presentation_mode": "offscreen",
"source_scene_entities": len(json.loads(scene.read_text())["entities"]),
"generated_scene_entities": len(generated_scene["entities"])},
"samples": samples,
"summary_seconds": {label: summarize([row["seconds"] for row in rows])
for label, rows in samples.items()},
"resource_lifecycle": resource_growth,
"large_scene_summary_ms": large_data["summary_ms"],
"coverage": {"windowed_first_presented_frame":
"unmeasured; only offscreen rendered frames were sampled",
"reference_release_budgets":
"not compared with this Debug development workflow"},
"method": [
"Fresh disposable C++ and Lua project copies; checked-in sources never modified.",
"Cold build means empty project .faset caches, not cold OS cache or dependency download.",
"Warm/changed C++ and header builds have one warm-up then five measured repetitions.",
"Lua edit-to-reload uses a persistent watched Player, one warm-up and five edits; "
"wall time includes up to 500 ms watcher polling and stderr notification.",
"First frame is Player main-to-first-completed offscreen frame; OS loader excluded.",
"UI latency uses 10 warm-up and 100 keyboard-input to offscreen-render frames; "
"there is no window presentation timing claim.",
"The 3,000-frame lifecycle compares maximum explicit Vulkan allocation and texture "
"counts in frames 101-200 and 2901-3000; it is not a whole-process leak proof.",
"Times are observations under ambient host load, not CI pass/fail thresholds."]}
validate_report(report)
(output / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
print(f"Report: {output / 'report.json'}", flush=True)
settings = json.loads((project / "project.faset.json").read_text(encoding="utf-8"))
run("changed-build-player-first-frame", [built["player"], "--scene",
project / settings["start_scene"], "--assets", project / ".faset/cache",