Build the DoE region reader + automated R/LaTeX report pipeline
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:
co-authored by
Claude Sonnet 5
parent
aaf349946f
commit
c535252907
Executable
+96
@@ -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"
|
||||
Executable
+194
@@ -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()
|
||||
Reference in New Issue
Block a user