Build the DoE region reader + automated R/LaTeX report pipeline
Build / build-amd64-iso (push) Canceled after 0s
Build / build-aarch64-iso (push) Canceled after 0s
Build / build-riscv64-img (push) Canceled after 0s

Closes the "still open" reader-tool note from §XXXV.10: scripts/
extract_doe_region.py spools DOE-PERSIST-TRIAL's on-disk binary ring off
disk/artemis.img into a CSV -- direct file read by default, or via a real
loop device (--loop, losetup) per the literal request. Independent
Python reimplementation of doe_region.c's own ring-walk, verified
against a live 3-record write/read round trip.

analyse_multiuser_doe.R (house style, ggplot2/svglite) and
multiuser_doe_report.tex (same preamble convention as
bare_metal_doe_report.tex) auto-discover whatever data exists and
degrade gracefully to a placeholder when a campaign has 0 trials so far
-- the whole point being this can be rebuilt at any point during a
multi-day campaign, including mid-run. build_multiuser_doe_report.sh
chains extraction -> R -> svg_to_pdf.py -> pdflatex into one command.

Found and fixed a real bug while verifying: the R script's SCRIPT_DIR
detection silently wrote charts/tables/report output to the repo root
instead of the analysis directory when invoked from a different cwd --
switched to the robust commandArgs() "--file=" idiom. Verified live both
branches (0 trials and a real 3-record write), reverted synthetic test
data and incidental svg_to_pdf.py churn on ~85 unrelated committed
figures before committing. FABRIC-3.md §XXXV.11 has the full account.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BWpNjdwPtFLuVLaAq44L9K
This commit is contained in:
Robert Allan James
2026-09-17 16:59:26 -04:00
co-authored by Claude Sonnet 5
parent aaf349946f
commit c535252907
14 changed files with 10083 additions and 1 deletions
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# build_multiuser_doe_report.sh — full pipeline: disk -> CSV -> R/SVG -> PDF
#
# Chains: extract_doe_region.py (spool DOE-PERSIST-TRIAL records off
# disk/artemis.img) -> analyse_multiuser_doe.R (tables + SVG charts +
# auto-generated summary fragment) -> svg_to_pdf.py (SVG -> PDF for
# \includegraphics) -> pdflatex (twice, for cross-references) ->
# multiuser_doe_report.pdf.
#
# Safe to re-run at any point, including while a multi-day campaign is
# still executing on a separate QEMU instance: every step is read-only
# with respect to disk/artemis.img, and analyse_multiuser_doe.R degrades
# to a "no data yet" placeholder rather than failing when the DoE region
# has 0 trials so far.
#
# Usage:
# scripts/build_multiuser_doe_report.sh [--loop] [--csv PATH]
#
# --loop Attach disk/artemis.img via losetup instead of reading
# the file directly (needs sudo). See extract_doe_region.py.
# --csv PATH Skip disk extraction entirely and analyse an existing
# CSV instead (e.g. to smoke-test this pipeline against
# an old per-tick run CSV, or a CSV saved from a previous
# extraction).
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
ANALYSIS_DIR="experiments/bare_metal/analysis"
RUNS_DIR="experiments/bare_metal/runs"
REPORT_DIR="$ANALYSIS_DIR/report"
CSV_OUT="$RUNS_DIR/multiuser-doe-trials.csv"
USE_LOOP=0
CSV_OVERRIDE=""
while [ $# -gt 0 ]; do
case "$1" in
--loop) USE_LOOP=1; shift ;;
--csv) CSV_OVERRIDE="$2"; shift 2 ;;
*) echo "Unknown argument: $1" >&2; exit 1 ;;
esac
done
echo "=== [1/4] Extracting trial records ==="
if [ -n "$CSV_OVERRIDE" ]; then
echo " Using existing CSV: $CSV_OVERRIDE (skipping disk extraction)"
CSV_OUT="$CSV_OVERRIDE"
elif [ -f disk/artemis.img ]; then
mkdir -p "$RUNS_DIR"
if [ "$USE_LOOP" -eq 1 ]; then
python3 scripts/extract_doe_region.py --img disk/artemis.img --loop -o "$CSV_OUT"
else
python3 scripts/extract_doe_region.py --img disk/artemis.img -o "$CSV_OUT"
fi
else
echo " disk/artemis.img not found -- writing an empty CSV so the report" >&2
echo " still builds with a 'no data yet' placeholder." >&2
mkdir -p "$RUNS_DIR"
echo "isa,run_id,cfg,rep,nw,mode,fail_count,fleet_k_q48,fleet_conserved,switch_count_cumulative" > "$CSV_OUT"
fi
echo ""
echo "=== [2/4] Running R analysis (tables + charts + summary fragment) ==="
Rscript "$ANALYSIS_DIR/analyse_multiuser_doe.R" "$CSV_OUT"
echo ""
echo "=== [3/4] Converting SVG charts to PDF ==="
python3 "$ANALYSIS_DIR/svg_to_pdf.py"
echo ""
echo "=== [4/4] Building PDF report ==="
mkdir -p "$REPORT_DIR/sections"
if [ ! -f "$REPORT_DIR/sections/multiuser_summary_autogen.tex" ]; then
echo "No trial data has been persisted to the DoE region yet." \
> "$REPORT_DIR/sections/multiuser_summary_autogen.tex"
fi
cd "$REPORT_DIR"
pdflatex -interaction=nonstopmode -halt-on-error multiuser_doe_report.tex >/tmp/multiuser_doe_report_latex1.log 2>&1 || {
echo "pdflatex (pass 1) failed -- see /tmp/multiuser_doe_report_latex1.log" >&2
tail -40 /tmp/multiuser_doe_report_latex1.log >&2
exit 1
}
pdflatex -interaction=nonstopmode -halt-on-error multiuser_doe_report.tex >/tmp/multiuser_doe_report_latex2.log 2>&1 || {
echo "pdflatex (pass 2) failed -- see /tmp/multiuser_doe_report_latex2.log" >&2
tail -40 /tmp/multiuser_doe_report_latex2.log >&2
exit 1
}
cd "$REPO_ROOT"
echo ""
echo "=== Done ==="
echo " PDF: $REPORT_DIR/multiuser_doe_report.pdf"
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""
extract_doe_region.py -- spool the DOE-PERSIST-TRIAL binary ring off
disk/artemis.img into a CSV, for the multiuser/multitasking DoE campaign
report (FABRIC-3.md §XXXV.10).
Reads the SAME devblock_from_top-addressed ring doe_region.c writes
(include/starkernel/doe_region.h has the authoritative layout: control
header at devblock_from_top=98, slot devblocks growable from 99, 64-byte
doe_trial_slot_t records, 64 slots/devblock). Two access modes:
--img PATH Read the image file directly (default). No privilege
needed, works on a plain regular file. This is what
"reading the drive" reduces to when the image is
just a file, which is the normal QEMU-dev case.
--loop PATH Attach PATH via losetup first (needs sudo), read the
resulting /dev/loopN node, then detach it again on
exit -- the literal "mount the virtual drive on a
loop device" step for when this runs against a real
block device or a copy pulled off real hardware. The
ring is raw binary at a fixed byte offset, not a
filesystem -- there is nothing to `mount` in the
POSIX-filesystem sense, so this attaches the loop
device and reads it exactly like a regular file.
Either mode is read-only and never modifies the image. Safe to run
against a live campaign's artemis.img (the ring's control header CRC
means a torn read mid-write is simply rejected as "ring not yet
initialized" by the same discipline doe_region.c itself uses -- see
doe_region_ctrl_read()); worst case is missing the very latest record
until the next run of this script.
"""
import argparse
import csv
import struct
import subprocess
import sys
from contextlib import contextmanager
from pathlib import Path
DEVBLOCK_SIZE = 4096
# Mirrors include/starkernel/doe_region.h and include/starkernel/log_region.h
ARTEMIS_SIG_DEVBLOCK_FROM_TOP = 64
LOG_REGION_DEVBLOCK_FROM_TOP_BASE = ARTEMIS_SIG_DEVBLOCK_FROM_TOP + 1 # 65
LOG_REGION_MAX_DEVBLOCKS = 32
DOE_REGION_DEVBLOCK_FROM_TOP_BASE = (
LOG_REGION_DEVBLOCK_FROM_TOP_BASE + 1 + LOG_REGION_MAX_DEVBLOCKS
) # 98
DOE_SLOT_SIZE = 64
DOE_SLOTS_PER_DEVBLOCK = DEVBLOCK_SIZE // DOE_SLOT_SIZE # 64
DOE_REGION_MAGIC = 0x44454F44 # 'DOED'
DOE_REGION_VERSION_0 = 0
# doe_region_ctrl_t: magic(u64) devblocks(u32) head_slot(u32) tail_slot(u32)
# record_count(u32) hdr_crc(u64), little-endian, rest is padding to 4096.
CTRL_FMT = "<QIIIIQ"
CTRL_SIZE = struct.calcsize(CTRL_FMT)
# doe_trial_slot_t: fleet_k_q48(u64) switch_count_cumulative(u64)
# run_id(u32) cfg(u32) rep(u32) nw(u32) mode(u32) fail_count(u32)
# fleet_conserved(u32) isa(8s), rest is padding to 64.
SLOT_FMT = "<QQIIIIIII8s"
SLOT_SIZE = struct.calcsize(SLOT_FMT)
assert SLOT_SIZE <= DOE_SLOT_SIZE
def dft_to_offset(devblock_from_top: int, total_devblocks: int) -> int:
idx = total_devblocks - 1 - devblock_from_top
if idx < 0:
raise ValueError(
f"devblock_from_top={devblock_from_top} exceeds total_devblocks={total_devblocks}"
)
return idx * DEVBLOCK_SIZE
@contextmanager
def loop_attach(img_path: Path):
"""Attach img_path via losetup (read-only), yield the /dev/loopN path,
detach on exit. Requires sudo -- the literal 'mount on a loop device'
step; nothing here is a filesystem mount."""
result = subprocess.run(
["sudo", "losetup", "--find", "--show", "--read-only", str(img_path)],
capture_output=True, text=True, check=True,
)
loop_dev = result.stdout.strip()
print(f" attached {img_path} -> {loop_dev}", file=sys.stderr)
try:
yield loop_dev
finally:
subprocess.run(["sudo", "losetup", "--detach", loop_dev], check=False)
print(f" detached {loop_dev}", file=sys.stderr)
def read_ring(dev_path: str):
"""Returns a list of dicts, one per live trial record, in ring order
(oldest first). Empty list if the ring has never been written."""
size = Path(dev_path).stat().st_size if not dev_path.startswith("/dev/") else None
if size is None:
with open(dev_path, "rb") as f:
f.seek(0, 2)
size = f.tell()
total_devblocks = size // DEVBLOCK_SIZE
if total_devblocks <= DOE_REGION_DEVBLOCK_FROM_TOP_BASE:
raise ValueError(
f"image too small ({size} bytes, {total_devblocks} devblocks) "
f"for the DOE region fence (needs > {DOE_REGION_DEVBLOCK_FROM_TOP_BASE})"
)
ctrl_off = dft_to_offset(DOE_REGION_DEVBLOCK_FROM_TOP_BASE, total_devblocks)
with open(dev_path, "rb") as f:
f.seek(ctrl_off)
ctrl_raw = f.read(DEVBLOCK_SIZE)
magic, devblocks, head_slot, tail_slot, record_count, hdr_crc = struct.unpack_from(
CTRL_FMT, ctrl_raw, 0
)
magic_lo = magic & 0xFFFFFFFF
version = (magic >> 32) & 0xFF
if magic_lo != DOE_REGION_MAGIC or version != DOE_REGION_VERSION_0:
print(" DOE region not yet initialized (no trials persisted yet)", file=sys.stderr)
return []
total_slots = devblocks * DOE_SLOTS_PER_DEVBLOCK
records = []
with open(dev_path, "rb") as f:
idx = head_slot
for _ in range(record_count):
slot_devblock_dft = DOE_REGION_DEVBLOCK_FROM_TOP_BASE + 1 + (idx // DOE_SLOTS_PER_DEVBLOCK)
slot_off_in_devblock = (idx % DOE_SLOTS_PER_DEVBLOCK) * DOE_SLOT_SIZE
blk_off = dft_to_offset(slot_devblock_dft, total_devblocks)
f.seek(blk_off + slot_off_in_devblock)
raw = f.read(SLOT_SIZE)
(fleet_k_q48, switch_count, run_id, cfg, rep, nw, mode,
fail_count, fleet_conserved, isa_raw) = struct.unpack(SLOT_FMT, raw)
isa = isa_raw.split(b"\x00", 1)[0].decode("ascii", errors="replace")
records.append({
"isa": isa,
"run_id": run_id,
"cfg": cfg,
"rep": rep,
"nw": nw,
"mode": mode,
"fail_count": fail_count,
"fleet_k_q48": fleet_k_q48,
"fleet_conserved": fleet_conserved,
"switch_count_cumulative": switch_count,
})
idx = (idx + 1) % total_slots
print(f" ring: devblocks={devblocks} head={head_slot} tail={tail_slot} "
f"record_count={record_count} -> {len(records)} records read", file=sys.stderr)
return records
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--img", default="disk/artemis.img", help="Path to the disk image (default: disk/artemis.img)")
ap.add_argument("--loop", action="store_true", help="Attach --img via losetup first (needs sudo)")
ap.add_argument("-o", "--out", default=None, help="Output CSV path (default: stdout)")
args = ap.parse_args()
img_path = Path(args.img)
if not img_path.exists():
print(f"extract_doe_region: image not found: {img_path}", file=sys.stderr)
sys.exit(1)
if args.loop:
with loop_attach(img_path) as loop_dev:
records = read_ring(loop_dev)
else:
records = read_ring(str(img_path))
fieldnames = ["isa", "run_id", "cfg", "rep", "nw", "mode", "fail_count",
"fleet_k_q48", "fleet_conserved", "switch_count_cumulative"]
out_f = open(args.out, "w", newline="") if args.out else sys.stdout
try:
writer = csv.DictWriter(out_f, fieldnames=fieldnames)
writer.writeheader()
for rec in records:
writer.writerow(rec)
finally:
if args.out:
out_f.close()
print(f" wrote {len(records)} rows -> {args.out}", file=sys.stderr)
if __name__ == "__main__":
main()