From e0de78a23b392023307ae1f9b7eb4b67f3564f63 Mon Sep 17 00:00:00 2001 From: Carl-W Date: Mon, 11 May 2026 21:58:57 +0200 Subject: [PATCH 01/10] =?UTF-8?q?shakedown:=20wave=205=20=E2=80=94=20calib?= =?UTF-8?q?ration=20submission=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Friends-can-try milestone: one-command runner that produces a predictable, PR-able JSON report. No hosted backend — submissions land via PR, CI guards against PII leakage, reviewer merges. - run-shakedown.sh orchestrator wraps inventory + battery + cpu-variance + thermal-load end-to-end. Prompts sudo upfront, aggregates into a SCHEMA-compliant JSON, writes Reports/local/.json (full, gitignored) + Reports/submissions/.json (sanitized, committable). Filename: {YYYY-MM-DD}-{preset}-{hash4}.json. - .gitignore: Reports/local/ ignored, Reports/submissions/ tracked. - CI submission audit (lint.yml): rejects PRs adding submission JSON that contains _raw_* fields, raw_log_path, plaintext serial_number, malformed filename, missing SCHEMA fields, or submission_safe != true. Skips silently when no submission files exist. - README "Submit a calibration report" section, CONTRIBUTING submission flow, PR-template checkbox. - Soft-failed the model_must_include inventory assert: Apple Silicon's machine_model (e.g. Mac17,1) doesn't reliably carry screen-size info, so a mismatch now warns instead of failing. chip_pattern and memory_gb remain hard asserts. --- .github/pull_request_template.md | 8 + .github/workflows/lint.yml | 91 ++++++ .gitignore | 7 +- CHANGELOG.md | 8 + CONTRIBUTING.md | 14 +- README.md | 25 +- Reports/submissions/.gitkeep | 0 Verification/scripts/run-shakedown.sh | 432 ++++++++++++++++++++++++++ 8 files changed, 581 insertions(+), 4 deletions(-) create mode 100644 Reports/submissions/.gitkeep create mode 100755 Verification/scripts/run-shakedown.sh diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 476bf31..878e7b3 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,6 +4,7 @@ ## Type of change +- [ ] **Calibration report submission** (`Reports/submissions/*.json` — see below) - [ ] Target preset (`targets/*.json`) - [ ] Generation calibration (`examples/-/`) - [ ] Script improvement (`Verification/scripts/`) @@ -11,6 +12,13 @@ - [ ] Docs - [ ] OSS hygiene (CI, templates, lint, etc.) +## Calibration report submission (skip if not applicable) + +- [ ] I generated this via `./Verification/scripts/run-shakedown.sh --target ` (not hand-rolled) +- [ ] I reviewed the submission JSON and confirmed no plaintext PII (serial, SSID, store name) +- [ ] Chip / RAM / chassis class: +- [ ] Overall verdict: + ## Validation If this changes a script or threshold, paste the JSON output of running the affected script on your Mac, plus your chip / RAM: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f676db7..d74e191 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -47,6 +47,97 @@ jobs: python3 -c "import ast,sys; ast.parse(open(sys.argv[1]).read())" "$py" done + - name: Audit Reports/submissions/*.json for PII and schema shape + # Walks Reports/submissions/*.json. Each file must: + # - have a filename matching {YYYY-MM-DD}-{preset}-{hash4}.json + # - contain all SCHEMA.md top-level fields (schema_version, target, unit, phases, result, …) + # - have submission_safe: true + # - contain NO _raw_* fields anywhere (would leak SSIDs / paired BT IDs / USB serials) + # - contain NO raw_log_path (thermal-load.sh's tempfile pointer) + # - contain NO unit.serial_number (only serial_hash is allowed) + # Skips silently if no submission files exist (the directory is empty for non-submission PRs). + run: | + python3 <<'PYEOF' + import glob + import json + import os + import re + import sys + + REQUIRED_TOP = ["schema_version", "shakedown_version", "timestamp", + "target", "unit", "phases", "result", "result_reason", + "submission_safe"] + REQUIRED_PHASES = ["0_preflight", "1_inventory", "2_battery", "3_sensors", + "4_cpu_variance", "5_thermal_load", "6_display", + "7_physical", "8_apple_diagnostics", "9_idle_drain"] + FILENAME_RE = re.compile(r"^\d{4}-\d{2}-\d{2}-[a-z0-9-]+-[0-9a-f]{4}\.json$") + + def find_keys(obj, predicate, path=""): + hits = [] + if isinstance(obj, dict): + for k, v in obj.items(): + sub = f"{path}.{k}" if path else k + if predicate(k): + hits.append(sub) + hits.extend(find_keys(v, predicate, sub)) + elif isinstance(obj, list): + for i, v in enumerate(obj): + hits.extend(find_keys(v, predicate, f"{path}[{i}]")) + return hits + + errors = [] + files = sorted(glob.glob("Reports/submissions/*.json")) + if not files: + print("no submission files; nothing to audit") + sys.exit(0) + + for path in files: + name = os.path.basename(path) + if not FILENAME_RE.match(name): + errors.append(f"{path}: filename does not match {{YYYY-MM-DD}}-{{preset}}-{{hash4}}.json") + continue + try: + with open(path) as f: + report = json.load(f) + except json.JSONDecodeError as e: + errors.append(f"{path}: invalid JSON: {e}") + continue + + for field in REQUIRED_TOP: + if field not in report: + errors.append(f"{path}: missing required top-level field '{field}'") + + phases = report.get("phases", {}) + for p in REQUIRED_PHASES: + if p not in phases: + errors.append(f"{path}: missing required phase '{p}'") + + if report.get("submission_safe") is not True: + errors.append(f"{path}: submission_safe must be true (got {report.get('submission_safe')!r})") + + raw_keys = find_keys(report, lambda k: k.startswith("_raw_")) + for k in raw_keys: + errors.append(f"{path}: forbidden _raw_* field at '{k}' (may contain SSIDs/paired BT IDs)") + + raw_log = find_keys(report, lambda k: k == "raw_log_path") + for k in raw_log: + errors.append(f"{path}: forbidden 'raw_log_path' field at '{k}'") + + unit = report.get("unit", {}) + if "serial_number" in unit: + errors.append(f"{path}: forbidden unit.serial_number (only serial_hash is allowed)") + + if report.get("result") not in ("PASS", "FAIL"): + errors.append(f"{path}: result must be 'PASS' or 'FAIL' (got {report.get('result')!r})") + + if errors: + print("Submission audit failed:", file=sys.stderr) + for e in errors: + print(f" - {e}", file=sys.stderr) + sys.exit(1) + print(f"audited {len(files)} submission file(s) — all clean") + PYEOF + - name: Verify markdown links resolve to existing files # Walk every in-repo .md, parse ](relative/path) links, confirm each # resolves to a file on disk. Skips http(s)://, mailto:, and diff --git a/.gitignore b/.gitignore index 7c2ce38..30535d7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,15 @@ .claude/settings.local.json .claude/worktrees/ -# Run outputs (may contain serials, system info) +# Run outputs (may contain serials, system info). +# The orchestrator splits each run into: +# Reports/local/ — full output with _raw_* fields (gitignored) +# Reports/submissions/ — sanitized for PR submission (committable) Reports/* !Reports/.gitkeep !Reports/SCHEMA.md +!Reports/submissions/ +Reports/local/ # Local scratch .macqa/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 87fda60..dc5a48d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project are documented here. Format follows [Keep a ## [Unreleased] +### Added — calibration submission workflow + +- **`run-shakedown.sh` orchestrator** — wraps the four auto-runnable phases (inventory, battery, CPU variance, thermal load) end-to-end, prompts sudo upfront, aggregates into a SCHEMA-compliant JSON, and emits a sanitized submission copy alongside the full local copy. One command instead of five-plus, with the predictable `{YYYY-MM-DD}-{preset}-{hash4}.json` filename convention. +- **`Reports/local/` vs `Reports/submissions/` split** — full output with `_raw_*` debug fields stays gitignored; sanitized copy is committable as a PR. Plaintext serials never reach the submission copy, even with `INCLUDE_PLAINTEXT_SERIAL=1`. +- **CI submission audit** — new step in `.github/workflows/lint.yml` triggered on PRs touching `Reports/submissions/**`. Rejects any `_raw_*` field, `raw_log_path`, plaintext `serial_number`, off-pattern filename, missing SCHEMA fields, or `submission_safe != true`. +- **README "Submit a calibration report" section** + CONTRIBUTING.md submission flow + PR-template checkbox for calibration submissions. Until a hosted aggregator exists, PRs are how the calibration corpus grows. +- **Soft inventory assert for `model_must_include`** — Apple Silicon's `machine_model` (e.g. `Mac17,1`) doesn't reliably contain the screen-size substring, so a mismatch now warns rather than fails. Chip and memory remain hard asserts (those are reliable from `system_profiler`). + ### Added — methodology hardening - **Phase 4 cold burst measurement** — first 5 s of parallel SHA-256 captured before warmup heats the chassis. Burst figure recorded as `burst_throughput_mb_per_s` for diagnostic comparison against the steady-state mean (advisory; doesn't drive verdict without a calibration baseline). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2b62b0f..8352e9e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,6 +74,16 @@ When the methodology changes: - Add an entry to [`CHANGELOG.md`](CHANGELOG.md) under the Unreleased section calling out the comparability impact. - If the change affects how reports compare across submissions (e.g. new metric, threshold tightening), call it out explicitly so the future aggregator can bucket old vs. new submissions. -## Reports submission (future) +## Submitting a calibration report -A planned hosted aggregator will accept opt-in submissions of `Reports/.json` to build crowd-sourced baselines per generation. Until that lands, the JSON format is the API contract — keep it stable, version it, and don't break old reports. +Until a hosted aggregator exists, the calibration corpus grows via PRs to `Reports/submissions/`. The orchestrator at `Verification/scripts/run-shakedown.sh` produces a sanitized submission JSON in the predictable filename convention (`{YYYY-MM-DD}-{preset}-{hash4}.json`); add that file in a PR. + +What to expect: + +1. **CI runs a submission audit** on any PR touching `Reports/submissions/**` — fails the PR if it sees `_raw_*` fields, a plaintext serial, an off-pattern filename, `submission_safe != true`, or missing SCHEMA-required fields. +2. **Reviewer checks for PII** beyond what CI catches (free-form notes, store location, etc. — the orchestrator never writes these unless you pass `--notes`). +3. **Merge** — your report lives in-repo, dated and attributable to your PR. + +Known-good (PASS) submissions are currently the most valuable: the v0.1 thresholds were derived from public reports rather than measured baselines, and a few clean runs from trusted submitters let us tighten "presumed-good" into "calibrated." + +The JSON format is the API contract — keep it stable, version it (`Reports/SCHEMA.md`), and don't break old reports. diff --git a/README.md b/README.md index 26540e8..95d48f2 100644 --- a/README.md +++ b/README.md @@ -164,9 +164,32 @@ sudo CHASSIS_CLASS="$CHASSIS_CLASS" ./Verification/scripts/thermal-load.sh > Rep The inline `sudo CHASSIS_CLASS=...` form preserves the env var across the privilege boundary regardless of sudoers `env_keep` config (otherwise `thermal-load.sh` falls back to `active-cooled-pro` defaults). Then read the values against [Pass-Fail Criteria](Verification/Pass-Fail%20Criteria.md). The agent flow is recommended though — most of the value is in the manual prompts and the cross-referencing against calibration notes. +## Submit a calibration report + +The v0.1 thresholds need real-world data to calibrate. If you ran the harness — PASS, WARN, or FAIL — please consider submitting your report. **Especially valuable: known-good machines from someone you trust**, which is what the methodology currently lacks. + +There's no hosted backend; submissions land via PR. + +```bash +./Verification/scripts/run-shakedown.sh --target mbp-16-m5-max-64 +``` + +The orchestrator runs the auto-runnable phases (preflight → inventory → battery → CPU variance → thermal load), aggregates into a SCHEMA-compliant JSON, and writes two copies: + +- `Reports/local/.json` — full output, **gitignored** (keeps `_raw_*` debug fields) +- `Reports/submissions/.json` — sanitized for PR submission + +To submit: + +1. Skim the submission JSON for any leftover PII. +2. `git checkout -b submit- && git add Reports/submissions/.json && git commit -m "submission: "` +3. Open a PR. CI runs the submission audit (rejects `_raw_*` leakage, plaintext serials, malformed schema, off-pattern filenames). + +The manual phases (display, physical inspection, Apple Diagnostics, idle drain) are emitted as `skipped` placeholders. If you ran any of them, hand-edit `Reports/local/.json`, re-sanitize, and overwrite the submission copy before opening the PR. + ## Roadmap -- **Hosted aggregator.** Submit your `Reports/.json` to a public site for crowd-sourced baselines per generation. Once we have N submissions from known-good units, the "baseline TBD" caveat in the variance test goes away — the per-SKU reference distribution comes from real data, and your unit's PASS/FAIL is contextualized against units of the same chip + memory + perf-cores. (See [`Reports/SCHEMA.md`](Reports/SCHEMA.md) — reports are designed for opt-in submission.) +- **Hosted aggregator.** Eventually, submission via API to a public site so reports aren't reviewed by hand. Until then, the PR-submission flow above *is* the aggregator — slower but no infra, and PR review catches PII before merge. - **Non-accelerated workload pass.** Optional Phase 4b that runs a workload without hardware acceleration (e.g. unaccelerated AES, BLAKE2b in pure Python, or a pinned matrix-multiply kernel) so the test stresses integer pipelines and memory bandwidth too. Catches batch defects that don't show up under SHA-NI / Apple Silicon's crypto engines. - **GPU variance test.** Currently CPU-only. M5 Max GPU is the bigger thermal contributor and a Metal compute load would be much more aggressive than CPU SHA-256. - **NVMe SSD performance.** Currently we only check SMART status — Apple has shipped 256 GB single-die SSD perf regressions on past gens, worth catching. diff --git a/Reports/submissions/.gitkeep b/Reports/submissions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Verification/scripts/run-shakedown.sh b/Verification/scripts/run-shakedown.sh new file mode 100755 index 0000000..a21e2e8 --- /dev/null +++ b/Verification/scripts/run-shakedown.sh @@ -0,0 +1,432 @@ +#!/bin/bash +# run-shakedown.sh — orchestrator: runs the auto-runnable phases end-to-end and +# writes two JSON reports: a full local copy and a sanitized submission copy. +# +# Usage: +# ./Verification/scripts/run-shakedown.sh --target mbp-16-m5-max-64 +# +# Writes: +# Reports/local/.json — full output, gitignored (keeps _raw_* fields) +# Reports/submissions/.json — sanitized, committable as a PR +# +# Phases 6 (display), 7 (physical), 8 (Apple Diagnostics), 9 (idle drain) emit +# `verdict: "skipped"` placeholders — friend hand-edits the local copy if they +# ran any of those, then re-runs the sanitize step or copies the result. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +TARGET="" +NOTES="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --target) + TARGET="${2:-}" + shift 2 + ;; + --notes) + NOTES="${2:-}" + shift 2 + ;; + -h|--help) + cat < [--notes "free-form notes"] + + --target required. Target preset name (file under targets/ without .json) + e.g. mbp-16-m5-max-64, macbook-air-m5-16, mbp-16-intel-2019 + --notes "..." optional free-form note to embed in the report. Setting any + note flips submission_safe to false (notes may contain PII). +HELP + exit 0 + ;; + *) + echo "Unknown arg: $1" >&2 + exit 2 + ;; + esac +done + +if [[ -z "$TARGET" ]]; then + echo "run-shakedown.sh: --target is required (see targets/README.md)" >&2 + echo " e.g. $(basename "$0") --target mbp-16-m5-max-64" >&2 + exit 2 +fi + +TARGET_FILE="$REPO_ROOT/targets/$TARGET.json" +if [[ ! -f "$TARGET_FILE" ]]; then + echo "run-shakedown.sh: target preset not found: $TARGET_FILE" >&2 + exit 2 +fi + +CHASSIS_CLASS=$(python3 - "$TARGET_FILE" <<'PYEOF' +import json +import sys +with open(sys.argv[1]) as f: + d = json.load(f) +print(d.get("thermal_chassis_class", "active-cooled-pro")) +PYEOF +) +export CHASSIS_CLASS + +echo "shakedown: target=$TARGET chassis_class=$CHASSIS_CLASS" +echo "shakedown: requesting sudo upfront (thermal phase needs it)" +sudo -v + +WORK=$(mktemp -d -t shakedown-run) +trap 'rm -rf "$WORK"' EXIT + +PREFLIGHT_TXT="$WORK/preflight.txt" +INVENTORY_JSON="$WORK/inventory.json" +BATTERY_JSON="$WORK/battery.json" +VARIANCE_JSON="$WORK/variance.json" +THERMAL_JSON="$WORK/thermal.json" + +echo "shakedown: Phase 0 — preflight (1 min)" +{ + echo "=== uptime ===" + uptime + echo "=== top -l 1 -n 5 -o cpu ===" + top -l 1 -n 5 -o cpu | tail -10 || true + echo "=== pmset -g ps ===" + pmset -g ps || true + echo "=== networksetup -getairportpower ===" + networksetup -getairportpower en0 2>/dev/null || echo "(no en0)" +} > "$PREFLIGHT_TXT" 2>&1 + +echo "shakedown: Phase 1 — inventory" +"$SCRIPT_DIR/inventory.sh" > "$INVENTORY_JSON" + +echo "shakedown: Phase 2 — battery" +"$SCRIPT_DIR/battery.sh" > "$BATTERY_JSON" + +echo "shakedown: Phase 4 — CPU variance (~6-10 min depending on chassis)" +"$SCRIPT_DIR/cpu-variance.sh" > "$VARIANCE_JSON" + +echo "shakedown: Phase 5 — sustained thermal load (~10 min, needs sudo)" +# shellcheck disable=SC2024 # the redirect target is a user-owned tempdir, not privileged. +sudo CHASSIS_CLASS="$CHASSIS_CLASS" "$SCRIPT_DIR/thermal-load.sh" > "$THERMAL_JSON" + +echo "shakedown: aggregating into canonical report" + +mkdir -p "$REPO_ROOT/Reports/local" "$REPO_ROOT/Reports/submissions" + +python3 - \ + "$TARGET_FILE" \ + "$PREFLIGHT_TXT" \ + "$INVENTORY_JSON" \ + "$BATTERY_JSON" \ + "$VARIANCE_JSON" \ + "$THERMAL_JSON" \ + "$REPO_ROOT/Reports/local" \ + "$REPO_ROOT/Reports/submissions" \ + "$TARGET" \ + "$NOTES" \ +<<'PYEOF' +import copy +import datetime +import json +import os +import re +import sys + +(target_file, preflight_txt, inv_path, bat_path, var_path, thr_path, + local_dir, submissions_dir, target_name, notes) = sys.argv[1:11] + +SHAKEDOWN_VERSION = "0.1.0" +SCHEMA_VERSION = "1.0" + +def load(path): + with open(path) as f: + return json.load(f) + +target = load(target_file) +inventory = load(inv_path) +battery = load(bat_path) +variance = load(var_path) +thermal = load(thr_path) + +inv_summary = inventory.get("summary", {}) + +with open(preflight_txt) as f: + preflight_raw = f.read() + +load_avg_1m = None +m = re.search(r"load averages?:\s+([\d.]+)", preflight_raw) +if m: + load_avg_1m = float(m.group(1)) +top_lines = [] +for line in preflight_raw.splitlines(): + if re.match(r"^\s*\d+\s+\S+\s+\d+\.\d+", line): + parts = line.split() + if len(parts) >= 3: + top_lines.append(f"{parts[1]} {parts[2]}%") +top_lines = top_lines[:5] +ac_power = "AC" in preflight_raw or "AC Power" in preflight_raw +wifi_on = "Wi-Fi Power (en0): On" in preflight_raw + +preflight_verdict = "pass" +preflight_reasons = [] +n_perf = inv_summary.get("perf_cores") or 0 +if load_avg_1m is not None and n_perf and load_avg_1m > n_perf * 0.5: + preflight_verdict = "warn" + preflight_reasons.append( + f"1m load avg {load_avg_1m:.2f} above half perf-core count ({n_perf}) — " + f"close background apps before trusting variance numbers" + ) +if not ac_power: + preflight_verdict = "warn" + preflight_reasons.append("not on AC power — sustained-perf tests assume AC") + +chip = inv_summary.get("chip") or "" +mem_gb = inv_summary.get("memory_gb") +# Search across model + model_identifier — the size suffix shows up in either +# field depending on generation (Intel "MacBookPro16,1" vs Apple Silicon "Mac17,1"). +model_haystack = " ".join(filter(None, [inv_summary.get("model"), inv_summary.get("model_identifier")])) + +inv_asserts = { + "chip_pattern_matched": bool(target.get("chip_pattern") and target["chip_pattern"] in chip), + "memory_gb_matched": (target.get("memory_gb") is None) or (target.get("memory_gb") == mem_gb), + "model_must_include_matched": bool(target.get("model_must_include") and target["model_must_include"] in model_haystack), +} +ssd_smart = None +for s in inventory.get("storage", []) or []: + if s.get("smart"): + ssd_smart = s["smart"] + break +if ssd_smart: + inv_asserts["ssd_smart"] = ssd_smart + +inv_verdict = "pass" +inv_reasons = [] +if not inv_asserts["chip_pattern_matched"]: + inv_verdict = "fail" + inv_reasons.append(f"chip '{chip}' does not contain target pattern '{target.get('chip_pattern')}'") +if not inv_asserts["memory_gb_matched"]: + inv_verdict = "fail" + inv_reasons.append(f"memory {mem_gb} GB does not match target {target.get('memory_gb')} GB") +if not inv_asserts["model_must_include_matched"]: + if inv_verdict == "pass": + inv_verdict = "warn" + inv_reasons.append( + f"model '{model_haystack}' does not include target substring '{target.get('model_must_include')}' " + f"— system_profiler does not reliably expose screen size on Apple Silicon; verify manually" + ) +if ssd_smart and ssd_smart != "Verified": + inv_verdict = "fail" + inv_reasons.append(f"SSD SMART status '{ssd_smart}' (expected 'Verified')") + +cycle = battery.get("cycle_count") +max_pct = battery.get("max_capacity_pct") +condition = battery.get("condition") + +bat_verdict = "pass" +bat_reasons = [] +if isinstance(cycle, int) and cycle > 5: + bat_verdict = "fail" + bat_reasons.append(f"cycle_count {cycle} > 5 — likely a returned/refurb unit, not new-from-factory") +elif isinstance(cycle, int) and cycle > 1: + bat_verdict = "warn" + bat_reasons.append(f"cycle_count {cycle} above the typical factory range (0–1)") +if isinstance(max_pct, (int, float)) and max_pct < 95: + bat_verdict = "fail" + bat_reasons.append(f"max_capacity_pct {max_pct}% < 95%") +elif isinstance(max_pct, (int, float)) and max_pct < 99: + if bat_verdict == "pass": + bat_verdict = "warn" + bat_reasons.append(f"max_capacity_pct {max_pct}% below the 99% expected on a new unit") +if condition and condition != "Normal": + bat_verdict = "fail" + bat_reasons.append(f"battery condition '{condition}' (expected 'Normal')") +if not bat_reasons: + bat_reasons.append("battery within new-unit range") + +battery_details = {k: v for k, v in battery.items() if not k.startswith("_") and k != "battery_serial"} + +sensors_required = ["Camera", "Microphone", "Wi-Fi", "Bluetooth"] +present = [] +missing = [] +cameras = inventory.get("cameras") or [] +if cameras: + present.append(f"Camera ({cameras[0]})") +else: + missing.append("Camera") +audio = inventory.get("audio") or [] +mic = any("microphone" in (a.get("name", "") or "").lower() for a in audio) or any( + a.get("input") for a in audio +) +spk = any(a.get("output") for a in audio) +if mic: + present.append("Microphone") +else: + missing.append("Microphone") +if spk: + present.append("Speakers") +else: + missing.append("Speakers") +if inventory.get("wifi_present"): + present.append("Wi-Fi") +else: + missing.append("Wi-Fi") +if inventory.get("bluetooth_present"): + present.append("Bluetooth") +else: + missing.append("Bluetooth") + +sensors_verdict = "fail" if missing else "pass" +sensors_reasons = ([f"missing: {', '.join(missing)}"] if missing else ["all expected sensors present"]) + +variance_verdict = variance.get("verdict", "fail") +variance_reasons = variance.get("verdict_reasons") or [] +variance_details = {k: v for k, v in variance.items() if k not in ("verdict", "verdict_reasons")} + +thermal_verdict = thermal.get("verdict", "fail") +thermal_reasons = thermal.get("verdict_reasons") or [] +thermal_details = {k: v for k, v in thermal.items() if k not in ("verdict", "verdict_reasons", "raw_log_path")} + +skipped_phases = { + "6_display": "run ./Verification/scripts/display-test.sh and fill in manual_responses", + "7_physical": "follow Runbook Phase 7 manual checklist", + "8_apple_diagnostics": "reboot into Diagnostics (Cmd-D from startup options); record code", + "9_idle_drain": "optional — see Runbook Phase 9", +} + +def phase_block(verdict, duration_s, details, reasons): + return { + "verdict": verdict, + "duration_s": duration_s, + "details": details, + "verdict_reasons": reasons, + } + +storage_gb = None +for s in inventory.get("storage", []) or []: + cap = s.get("capacity") or "" + m = re.search(r"([\d,.]+)\s*(TB|GB)", cap) + if m: + val = float(m.group(1).replace(",", "")) + if m.group(2) == "TB": + val *= 1024 + storage_gb = int(round(val)) + break + +unit_block = { + "model": inv_summary.get("model"), + "model_identifier": inv_summary.get("model_identifier"), + "chip": inv_summary.get("chip"), + "is_apple_silicon": inv_summary.get("is_apple_silicon"), + "perf_cores": inv_summary.get("perf_cores"), + "efficiency_cores": inv_summary.get("efficiency_cores"), + "logical_cpus": inv_summary.get("logical_cpus"), + "memory_gb": inv_summary.get("memory_gb"), + "storage_gb": storage_gb, + "macos_version": inv_summary.get("macos_version"), + "kernel_version": inv_summary.get("kernel_version"), + "serial_hash": inv_summary.get("serial_hash"), + "power_adapter": inventory.get("power_adapter"), +} +if inv_summary.get("serial_number"): + unit_block["serial_number"] = inv_summary["serial_number"] + +phases = { + "0_preflight": phase_block(preflight_verdict, 60, { + "load_avg_1m": load_avg_1m, + "top_cpu_consumers": top_lines, + "ac_power_connected": ac_power, + "wifi_connected": wifi_on, + }, preflight_reasons or ["system quiet"]), + "1_inventory": phase_block(inv_verdict, 1, {"asserts": inv_asserts}, inv_reasons or ["target asserts matched"]), + "2_battery": phase_block(bat_verdict, 1, battery_details, bat_reasons), + "3_sensors": phase_block(sensors_verdict, 1, {"expected_present": present, "missing": missing}, sensors_reasons), + "4_cpu_variance": phase_block(variance_verdict, variance_details.get("warmup_sec", 0) + variance_details.get("iterations", 0) * variance_details.get("seconds_per_iter", 0) + variance_details.get("burst_sec", 0), variance_details, variance_reasons), + "5_thermal_load": phase_block(thermal_verdict, thermal_details.get("duration_s", 600), thermal_details, thermal_reasons), +} +for name, hint in skipped_phases.items(): + phases[name] = phase_block("skipped", 0, {"note": hint}, ["not run by orchestrator"]) + +phase_verdicts = [p["verdict"] for p in phases.values()] +if "fail" in phase_verdicts: + result = "FAIL" + failing = [name for name, p in phases.items() if p["verdict"] == "fail"] + result_reason = f"failed phases: {', '.join(failing)}" +elif "warn" in phase_verdicts: + result = "PASS" + warning = [name for name, p in phases.items() if p["verdict"] == "warn"] + result_reason = f"all phases passed, warns on: {', '.join(warning)}" +else: + result = "PASS" + result_reason = "all phases passed; no defect signatures detected" + +target_block = {"preset": target_name} +target_block.update({k: v for k, v in target.items() if not k.startswith("_")}) + +report_full = { + "schema_version": SCHEMA_VERSION, + "shakedown_version": SHAKEDOWN_VERSION, + "timestamp": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "target": target_block, + "unit": unit_block, + "phases": phases, + "result": result, + "result_reason": result_reason, + "submission_safe": True, + "store_location": None, + "purchase_date": None, +} +if notes: + report_full["notes"] = notes + report_full["submission_safe"] = False + +report_full["phases"]["1_inventory"]["details"]["_raw_inventory"] = inventory +report_full["phases"]["2_battery"]["details"]["_raw_battery"] = battery + +date_str = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d") +serial_hash = inv_summary.get("serial_hash") or "" +m = re.search(r"sha256:([0-9a-f]{4,})", serial_hash) +hash4 = m.group(1)[:4] if m else "xxxx" +filename = f"{date_str}-{target_name}-{hash4}.json" +local_path = os.path.join(local_dir, filename) +submission_path = os.path.join(submissions_dir, filename) + +with open(local_path, "w") as f: + json.dump(report_full, f, indent=2, default=str) + +report_sub = copy.deepcopy(report_full) +report_sub["unit"].pop("serial_number", None) +for phase_name, phase in report_sub["phases"].items(): + details = phase.get("details", {}) + for k in list(details.keys()): + if k.startswith("_raw_"): + details.pop(k) + if "raw_log_path" in details: + details.pop("raw_log_path") +report_sub["submission_safe"] = not bool(notes) +if notes: + report_sub["notes"] = notes + +with open(submission_path, "w") as f: + json.dump(report_sub, f, indent=2, default=str) + +print(json.dumps({ + "result": result, + "result_reason": result_reason, + "local_path": local_path, + "submission_path": submission_path, + "submission_safe": report_sub["submission_safe"], +}, indent=2)) +PYEOF + +cat <&2 + +shakedown: done. + +Next steps: + 1. Review the submission JSON for any leftover PII: + cat Reports/submissions/ + 2. If you ran the manual phases (display / physical / Apple Diagnostics), + hand-edit the local file then copy a sanitized version to submissions/. + 3. Open a PR adding the submission JSON to help calibrate v0.1 thresholds. + See CONTRIBUTING.md for the submission flow. +INFO From 4dad9dc0e6dd57cb584615d55a8ac348f8c38c89 Mon Sep 17 00:00:00 2001 From: Carl-W Date: Mon, 11 May 2026 22:52:01 +0200 Subject: [PATCH 02/10] =?UTF-8?q?shakedown:=20wave=205=20=E2=80=94=20make?= =?UTF-8?q?=20--target=20optional?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapt the orchestrator to Macs that don't have a preset yet, or existing units being self-tested rather than verified as new. - Without --target: chassis class is auto-detected from system_profiler (machine_name + chip_type → fanless / active-cooled-pro / desktop / intel-laptop / intel-desktop). Overridable with CHASSIS_CLASS env var. - Inventory phase: target asserts skipped, records actuals only. - Battery phase: factory-fresh checks (cycle_count, ≥99% capacity) skipped — those make sense for a new-purchase verification but false-fail any used Mac. Real-degradation checks (<95% capacity, abnormal condition, bad SMART) still apply. - Filename slug becomes {chassis}-{memory}gb when no preset is given. --- CHANGELOG.md | 1 + README.md | 8 ++ Verification/scripts/run-shakedown.sh | 161 +++++++++++++++++--------- 3 files changed, 118 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc5a48d..ab89678 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project are documented here. Format follows [Keep a ### Added — calibration submission workflow - **`run-shakedown.sh` orchestrator** — wraps the four auto-runnable phases (inventory, battery, CPU variance, thermal load) end-to-end, prompts sudo upfront, aggregates into a SCHEMA-compliant JSON, and emits a sanitized submission copy alongside the full local copy. One command instead of five-plus, with the predictable `{YYYY-MM-DD}-{preset}-{hash4}.json` filename convention. +- **`--target` is optional** — running without a preset auto-detects chassis class from `system_profiler` (override with `CHASSIS_CLASS` env var), skips inventory asserts, and softens the battery "factory-fresh" checks (cycle count, ≥99% capacity) since those only make sense on a new-purchase verification. Real-degradation checks still apply. Filename becomes `{YYYY-MM-DD}-{chassis}-{memory}gb-{hash4}.json`. - **`Reports/local/` vs `Reports/submissions/` split** — full output with `_raw_*` debug fields stays gitignored; sanitized copy is committable as a PR. Plaintext serials never reach the submission copy, even with `INCLUDE_PLAINTEXT_SERIAL=1`. - **CI submission audit** — new step in `.github/workflows/lint.yml` triggered on PRs touching `Reports/submissions/**`. Rejects any `_raw_*` field, `raw_log_path`, plaintext `serial_number`, off-pattern filename, missing SCHEMA fields, or `submission_safe != true`. - **README "Submit a calibration report" section** + CONTRIBUTING.md submission flow + PR-template checkbox for calibration submissions. Until a hosted aggregator exists, PRs are how the calibration corpus grows. diff --git a/README.md b/README.md index 95d48f2..ae6269c 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,14 @@ There's no hosted backend; submissions land via PR. ./Verification/scripts/run-shakedown.sh --target mbp-16-m5-max-64 ``` +Or without a preset (for Macs that don't have one yet, or existing units you're self-testing rather than verifying as new): + +```bash +./Verification/scripts/run-shakedown.sh +``` + +Without `--target`, chassis class is auto-detected from `system_profiler` (override with `CHASSIS_CLASS=intel-laptop ./Verification/scripts/run-shakedown.sh`), and the inventory + "is this factory-fresh?" battery asserts are skipped — real-degradation signals (battery capacity < 95%, abnormal condition, SSD SMART status) still fail the run. + The orchestrator runs the auto-runnable phases (preflight → inventory → battery → CPU variance → thermal load), aggregates into a SCHEMA-compliant JSON, and writes two copies: - `Reports/local/.json` — full output, **gitignored** (keeps `_raw_*` debug fields) diff --git a/Verification/scripts/run-shakedown.sh b/Verification/scripts/run-shakedown.sh index a21e2e8..71a794e 100755 --- a/Verification/scripts/run-shakedown.sh +++ b/Verification/scripts/run-shakedown.sh @@ -33,12 +33,17 @@ while [[ $# -gt 0 ]]; do ;; -h|--help) cat < [--notes "free-form notes"] +Usage: $(basename "$0") [--target ] [--notes "free-form notes"] - --target required. Target preset name (file under targets/ without .json) - e.g. mbp-16-m5-max-64, macbook-air-m5-16, mbp-16-intel-2019 + --target optional. Target preset name (file under targets/ without .json) + e.g. mbp-16-m5-max-64, macbook-air-m5-16, mbp-16-intel-2019. + Without a target, inventory asserts are skipped — useful for + Macs that don't yet have a preset. --notes "..." optional free-form note to embed in the report. Setting any note flips submission_safe to false (notes may contain PII). + +Chassis class: read from the preset when --target is given; otherwise +auto-detected from system_profiler (override with CHASSIS_CLASS env var). HELP exit 0 ;; @@ -49,19 +54,14 @@ HELP esac done -if [[ -z "$TARGET" ]]; then - echo "run-shakedown.sh: --target is required (see targets/README.md)" >&2 - echo " e.g. $(basename "$0") --target mbp-16-m5-max-64" >&2 - exit 2 -fi - -TARGET_FILE="$REPO_ROOT/targets/$TARGET.json" -if [[ ! -f "$TARGET_FILE" ]]; then - echo "run-shakedown.sh: target preset not found: $TARGET_FILE" >&2 - exit 2 -fi - -CHASSIS_CLASS=$(python3 - "$TARGET_FILE" <<'PYEOF' +TARGET_FILE="" +if [[ -n "$TARGET" ]]; then + TARGET_FILE="$REPO_ROOT/targets/$TARGET.json" + if [[ ! -f "$TARGET_FILE" ]]; then + echo "run-shakedown.sh: target preset not found: $TARGET_FILE" >&2 + exit 2 + fi + CHASSIS_CLASS=$(python3 - "$TARGET_FILE" <<'PYEOF' import json import sys with open(sys.argv[1]) as f: @@ -69,9 +69,41 @@ with open(sys.argv[1]) as f: print(d.get("thermal_chassis_class", "active-cooled-pro")) PYEOF ) +elif [[ -z "${CHASSIS_CLASS:-}" ]]; then + # No target and no override — auto-detect chassis from machine_name. + # Mac Pro (Intel desktop) and MacBook Pro both contain "Pro"; check for + # the MacBook prefix first so Mac Pro doesn't get misclassified as a laptop. + CHASSIS_CLASS=$(python3 <<'PYEOF' +import json +import subprocess +import sys +try: + sp = json.loads(subprocess.check_output( + ["system_profiler", "-json", "SPHardwareDataType"], + stderr=subprocess.DEVNULL, + )) + hw = sp["SPHardwareDataType"][0] +except Exception: + sys.exit(1) +model = hw.get("machine_name") or "" +is_apple_silicon = bool(hw.get("chip_type")) +is_laptop = "MacBook" in model +if is_apple_silicon: + print("fanless" if (is_laptop and "Air" in model) else + "active-cooled-pro" if is_laptop else "desktop") +else: + print("intel-laptop" if is_laptop else "intel-desktop") +PYEOF +) || { + echo "run-shakedown.sh: could not auto-detect chassis class." >&2 + echo " Set CHASSIS_CLASS env var explicitly:" >&2 + echo " fanless | active-cooled-pro | desktop | intel-laptop | intel-desktop" >&2 + exit 2 + } +fi export CHASSIS_CLASS -echo "shakedown: target=$TARGET chassis_class=$CHASSIS_CLASS" +echo "shakedown: target=${TARGET:-} chassis_class=$CHASSIS_CLASS" echo "shakedown: requesting sudo upfront (thermal phase needs it)" sudo -v @@ -142,7 +174,7 @@ def load(path): with open(path) as f: return json.load(f) -target = load(target_file) +target = load(target_file) if target_file else None inventory = load(inv_path) battery = load(bat_path) variance = load(var_path) @@ -186,34 +218,41 @@ mem_gb = inv_summary.get("memory_gb") # field depending on generation (Intel "MacBookPro16,1" vs Apple Silicon "Mac17,1"). model_haystack = " ".join(filter(None, [inv_summary.get("model"), inv_summary.get("model_identifier")])) -inv_asserts = { - "chip_pattern_matched": bool(target.get("chip_pattern") and target["chip_pattern"] in chip), - "memory_gb_matched": (target.get("memory_gb") is None) or (target.get("memory_gb") == mem_gb), - "model_must_include_matched": bool(target.get("model_must_include") and target["model_must_include"] in model_haystack), -} ssd_smart = None for s in inventory.get("storage", []) or []: if s.get("smart"): ssd_smart = s["smart"] break -if ssd_smart: - inv_asserts["ssd_smart"] = ssd_smart inv_verdict = "pass" inv_reasons = [] -if not inv_asserts["chip_pattern_matched"]: - inv_verdict = "fail" - inv_reasons.append(f"chip '{chip}' does not contain target pattern '{target.get('chip_pattern')}'") -if not inv_asserts["memory_gb_matched"]: - inv_verdict = "fail" - inv_reasons.append(f"memory {mem_gb} GB does not match target {target.get('memory_gb')} GB") -if not inv_asserts["model_must_include_matched"]: - if inv_verdict == "pass": - inv_verdict = "warn" - inv_reasons.append( - f"model '{model_haystack}' does not include target substring '{target.get('model_must_include')}' " - f"— system_profiler does not reliably expose screen size on Apple Silicon; verify manually" - ) +inv_asserts = {} + +if target: + inv_asserts = { + "chip_pattern_matched": bool(target.get("chip_pattern") and target["chip_pattern"] in chip), + "memory_gb_matched": (target.get("memory_gb") is None) or (target.get("memory_gb") == mem_gb), + "model_must_include_matched": bool(target.get("model_must_include") and target["model_must_include"] in model_haystack), + } + if ssd_smart: + inv_asserts["ssd_smart"] = ssd_smart + if not inv_asserts["chip_pattern_matched"]: + inv_verdict = "fail" + inv_reasons.append(f"chip '{chip}' does not contain target pattern '{target.get('chip_pattern')}'") + if not inv_asserts["memory_gb_matched"]: + inv_verdict = "fail" + inv_reasons.append(f"memory {mem_gb} GB does not match target {target.get('memory_gb')} GB") + if not inv_asserts["model_must_include_matched"]: + if inv_verdict == "pass": + inv_verdict = "warn" + inv_reasons.append( + f"model '{model_haystack}' does not include target substring '{target.get('model_must_include')}' " + f"— system_profiler does not reliably expose screen size on Apple Silicon; verify manually" + ) +else: + inv_asserts = {"ran_without_target": True, "ssd_smart": ssd_smart} + inv_reasons.append("no target preset specified — recorded actual values without asserting") + if ssd_smart and ssd_smart != "Verified": inv_verdict = "fail" inv_reasons.append(f"SSD SMART status '{ssd_smart}' (expected 'Verified')") @@ -224,24 +263,29 @@ condition = battery.get("condition") bat_verdict = "pass" bat_reasons = [] -if isinstance(cycle, int) and cycle > 5: - bat_verdict = "fail" - bat_reasons.append(f"cycle_count {cycle} > 5 — likely a returned/refurb unit, not new-from-factory") -elif isinstance(cycle, int) and cycle > 1: - bat_verdict = "warn" - bat_reasons.append(f"cycle_count {cycle} above the typical factory range (0–1)") +# Cycle count + "below 99%" warn assume a new-from-factory unit; only apply +# them when --target is given (signals "I'm verifying a new purchase"). +# Real-degradation checks (<95% capacity, abnormal condition) apply either way. +if target: + if isinstance(cycle, int) and cycle > 5: + bat_verdict = "fail" + bat_reasons.append(f"cycle_count {cycle} > 5 — likely a returned/refurb unit, not new-from-factory") + elif isinstance(cycle, int) and cycle > 1: + bat_verdict = "warn" + bat_reasons.append(f"cycle_count {cycle} above the typical factory range (0–1)") + if isinstance(max_pct, (int, float)) and max_pct < 99 and (not isinstance(max_pct, (int, float)) or max_pct >= 95): + bat_verdict = "warn" + bat_reasons.append(f"max_capacity_pct {max_pct}% below the 99% expected on a new unit") +elif isinstance(cycle, int): + bat_reasons.append(f"cycle_count {cycle} (informational — no target, factory-fresh check skipped)") if isinstance(max_pct, (int, float)) and max_pct < 95: bat_verdict = "fail" bat_reasons.append(f"max_capacity_pct {max_pct}% < 95%") -elif isinstance(max_pct, (int, float)) and max_pct < 99: - if bat_verdict == "pass": - bat_verdict = "warn" - bat_reasons.append(f"max_capacity_pct {max_pct}% below the 99% expected on a new unit") if condition and condition != "Normal": bat_verdict = "fail" bat_reasons.append(f"battery condition '{condition}' (expected 'Normal')") if not bat_reasons: - bat_reasons.append("battery within new-unit range") + bat_reasons.append("battery healthy") battery_details = {k: v for k, v in battery.items() if not k.startswith("_") and k != "battery_serial"} @@ -359,8 +403,15 @@ else: result = "PASS" result_reason = "all phases passed; no defect signatures detected" -target_block = {"preset": target_name} -target_block.update({k: v for k, v in target.items() if not k.startswith("_")}) +if target: + target_block = {"preset": target_name} + target_block.update({k: v for k, v in target.items() if not k.startswith("_")}) +else: + target_block = { + "preset": None, + "thermal_chassis_class": variance.get("chassis_class"), + "note": "ran without --target — inventory asserts skipped", + } report_full = { "schema_version": SCHEMA_VERSION, @@ -386,7 +437,13 @@ date_str = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d") serial_hash = inv_summary.get("serial_hash") or "" m = re.search(r"sha256:([0-9a-f]{4,})", serial_hash) hash4 = m.group(1)[:4] if m else "xxxx" -filename = f"{date_str}-{target_name}-{hash4}.json" +if target_name: + slug = target_name +else: + chassis_slug = variance.get("chassis_class") or "unknown" + mem_slug = f"{mem_gb}gb" if mem_gb else "unknown" + slug = f"{chassis_slug}-{mem_slug}" +filename = f"{date_str}-{slug}-{hash4}.json" local_path = os.path.join(local_dir, filename) submission_path = os.path.join(submissions_dir, filename) From ca336379375afe6eac9098f440915f32d8b50885 Mon Sep 17 00:00:00 2001 From: Carl-W Date: Mon, 11 May 2026 22:56:06 +0200 Subject: [PATCH 03/10] =?UTF-8?q?shakedown:=20wave=205=20=E2=80=94=20confi?= =?UTF-8?q?rmation=20prompt=20before=20sustained=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "this will run ~18 min of 100% CPU load, proceed? [y/N]" prompt before sudo is requested, so the user has a clear bail-out point before committing to the run. Defaults to abort — only proceeds on explicit y/Y. SHAKEDOWN_YES=1 bypasses the prompt for scripted / unattended runs. Reassurance text emphasizes that macOS handles thermal protection, so the risk is noisy fans, not a damaged machine. --- Verification/scripts/run-shakedown.sh | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Verification/scripts/run-shakedown.sh b/Verification/scripts/run-shakedown.sh index 71a794e..a7523a1 100755 --- a/Verification/scripts/run-shakedown.sh +++ b/Verification/scripts/run-shakedown.sh @@ -104,6 +104,23 @@ fi export CHASSIS_CLASS echo "shakedown: target=${TARGET:-} chassis_class=$CHASSIS_CLASS" + +if [[ -z "${SHAKEDOWN_YES:-}" ]]; then + cat <&2 + +About to run ~18 min of sustained 100% CPU load (Phase 4 variance + Phase 5 +thermal). Fans will spin up loud and the chassis will get hot. macOS throttles +to protect the chip, so nothing dangerous — but expect a noisy 20 min. + +Set SHAKEDOWN_YES=1 to skip this prompt (e.g. for scripted runs). +INFO + read -r -p "Proceed? [y/N] " ans + if [[ ! "$ans" =~ ^[Yy] ]]; then + echo "shakedown: aborted before any load was run" >&2 + exit 1 + fi +fi + echo "shakedown: requesting sudo upfront (thermal phase needs it)" sudo -v From c33484aea9db843fa84ace34e493b5152d78356e Mon Sep 17 00:00:00 2001 From: Carl-W Date: Tue, 12 May 2026 09:20:07 +0200 Subject: [PATCH 04/10] =?UTF-8?q?shakedown:=20wave=205=20=E2=80=94=20./run?= =?UTF-8?q?=20entry,=20banner,=20sudo=20keep-alive,=20--no-sudo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX polish based on the first real-hardware run. - ./run shim at the repo root: thin exec wrapper so `cd ~/mac-shakedown && ./run` works instead of the longer `./Verification/scripts/run-shakedown.sh`. - Flame ASCII banner on startup (red gradient, bold SHAKEDOWN block letters, dim subtitle). Suppressed when stderr isn't a TTY so CI / piped runs stay clean. - Sudo keep-alive: background `sudo -n true` every 60s while the orchestrator runs. Fixes the double-prompt on Intel where Phase 4 (~8 min) outlasts macOS's default 5-min sudo timestamp. - --no-sudo (alias --skip-thermal): skip Phase 5, the only sudo-requiring phase. Phase 4 variance still runs as the headline test. ~half the runtime with no password prompt at all. --- CHANGELOG.md | 4 ++ README.md | 9 ++- Verification/scripts/run-shakedown.sh | 88 +++++++++++++++++++++++---- run | 3 + 4 files changed, 90 insertions(+), 14 deletions(-) create mode 100755 run diff --git a/CHANGELOG.md b/CHANGELOG.md index ab89678..73c251e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project are documented here. Format follows [Keep a - **`run-shakedown.sh` orchestrator** — wraps the four auto-runnable phases (inventory, battery, CPU variance, thermal load) end-to-end, prompts sudo upfront, aggregates into a SCHEMA-compliant JSON, and emits a sanitized submission copy alongside the full local copy. One command instead of five-plus, with the predictable `{YYYY-MM-DD}-{preset}-{hash4}.json` filename convention. - **`--target` is optional** — running without a preset auto-detects chassis class from `system_profiler` (override with `CHASSIS_CLASS` env var), skips inventory asserts, and softens the battery "factory-fresh" checks (cycle count, ≥99% capacity) since those only make sense on a new-purchase verification. Real-degradation checks still apply. Filename becomes `{YYYY-MM-DD}-{chassis}-{memory}gb-{hash4}.json`. +- **`./run` convenience entry** — thin shim at the repo root, execs the orchestrator with all args forwarded. Means the public command is `./run` after `cd ~/mac-shakedown`, not the longer `./Verification/scripts/run-shakedown.sh`. +- **`--no-sudo` (alias `--skip-thermal`)** — skips Phase 5, which is the only phase that needs sudo. Phase 4 (variance) still runs and is the headline test. Half the runtime, no password. +- **Sudo keep-alive** — background loop refreshes `sudo -n true` every 60 s while the orchestrator runs. macOS default sudo timestamp is 5 min and Phase 4 on Intel takes ~8 min, so the user would otherwise hit a second password prompt mid-run. +- **Flame banner** — colored ASCII banner on startup, suppressed when stderr isn't a TTY (CI / piped output stays clean). - **`Reports/local/` vs `Reports/submissions/` split** — full output with `_raw_*` debug fields stays gitignored; sanitized copy is committable as a PR. Plaintext serials never reach the submission copy, even with `INCLUDE_PLAINTEXT_SERIAL=1`. - **CI submission audit** — new step in `.github/workflows/lint.yml` triggered on PRs touching `Reports/submissions/**`. Rejects any `_raw_*` field, `raw_log_path`, plaintext `serial_number`, off-pattern filename, missing SCHEMA fields, or `submission_safe != true`. - **README "Submit a calibration report" section** + CONTRIBUTING.md submission flow + PR-template checkbox for calibration submissions. Until a hosted aggregator exists, PRs are how the calibration corpus grows. diff --git a/README.md b/README.md index ae6269c..3cce0d5 100644 --- a/README.md +++ b/README.md @@ -171,16 +171,19 @@ The v0.1 thresholds need real-world data to calibrate. If you ran the harness There's no hosted backend; submissions land via PR. ```bash -./Verification/scripts/run-shakedown.sh --target mbp-16-m5-max-64 +cd ~/mac-shakedown +./run --target mbp-16-m5-max-64 ``` Or without a preset (for Macs that don't have one yet, or existing units you're self-testing rather than verifying as new): ```bash -./Verification/scripts/run-shakedown.sh +./run ``` -Without `--target`, chassis class is auto-detected from `system_profiler` (override with `CHASSIS_CLASS=intel-laptop ./Verification/scripts/run-shakedown.sh`), and the inventory + "is this factory-fresh?" battery asserts are skipped — real-degradation signals (battery capacity < 95%, abnormal condition, SSD SMART status) still fail the run. +Without `--target`, chassis class is auto-detected from `system_profiler` (override with `CHASSIS_CLASS=intel-laptop ./run`), and the inventory + "is this factory-fresh?" battery asserts are skipped — real-degradation signals (battery capacity < 95%, abnormal condition, SSD SMART status) still fail the run. + +`./run --no-sudo` skips Phase 5 (the only phase that needs sudo), so you can run a no-password variance-only pass — about half the runtime. The orchestrator runs the auto-runnable phases (preflight → inventory → battery → CPU variance → thermal load), aggregates into a SCHEMA-compliant JSON, and writes two copies: diff --git a/Verification/scripts/run-shakedown.sh b/Verification/scripts/run-shakedown.sh index a7523a1..7e10e48 100755 --- a/Verification/scripts/run-shakedown.sh +++ b/Verification/scripts/run-shakedown.sh @@ -20,6 +20,7 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" TARGET="" NOTES="" +NO_SUDO=0 while [[ $# -gt 0 ]]; do case "$1" in @@ -31,14 +32,21 @@ while [[ $# -gt 0 ]]; do NOTES="${2:-}" shift 2 ;; + --no-sudo|--skip-thermal) + NO_SUDO=1 + shift + ;; -h|--help) cat <] [--notes "free-form notes"] +Usage: $(basename "$0") [--target ] [--no-sudo] [--notes "free-form notes"] --target optional. Target preset name (file under targets/ without .json) e.g. mbp-16-m5-max-64, macbook-air-m5-16, mbp-16-intel-2019. Without a target, inventory asserts are skipped — useful for Macs that don't yet have a preset. + --no-sudo skip Phase 5 (sustained thermal load) — that's the only phase + that needs sudo. Phase 4 (variance) still runs and is the + headline test. Alias: --skip-thermal. --notes "..." optional free-form note to embed in the report. Setting any note flips submission_safe to false (notes may contain PII). @@ -103,14 +111,52 @@ PYEOF fi export CHASSIS_CLASS -echo "shakedown: target=${TARGET:-} chassis_class=$CHASSIS_CLASS" +banner() { + if [[ ! -t 2 ]]; then return; fi + local Y=$'\033[93m' # bright yellow (wisps) + local O=$'\033[33m' # yellow (mid) + local R=$'\033[91m' # bright red (base) + local B=$'\033[1;31m' # bold red (letters) + local D=$'\033[2m' # dim (subtitle) + local X=$'\033[0m' # reset + cat >&2 <} chassis_class=$CHASSIS_CLASS mode=--no-sudo (Phase 5 will be skipped)" +else + echo "shakedown: target=${TARGET:-} chassis_class=$CHASSIS_CLASS" +fi if [[ -z "${SHAKEDOWN_YES:-}" ]]; then + if [[ "$NO_SUDO" -eq 1 ]]; then + duration_hint="~8 min of sustained 100% CPU load (Phase 4 variance)" + else + duration_hint="~18 min of sustained 100% CPU load (Phase 4 variance + Phase 5 thermal)" + fi cat <&2 -About to run ~18 min of sustained 100% CPU load (Phase 4 variance + Phase 5 -thermal). Fans will spin up loud and the chassis will get hot. macOS throttles -to protect the chip, so nothing dangerous — but expect a noisy 20 min. +About to run $duration_hint. Fans will spin up loud and the chassis will get +hot. macOS throttles to protect the chip, so nothing dangerous — but expect a +noisy run. Set SHAKEDOWN_YES=1 to skip this prompt (e.g. for scripted runs). INFO @@ -121,11 +167,24 @@ INFO fi fi -echo "shakedown: requesting sudo upfront (thermal phase needs it)" -sudo -v +SUDO_KEEPALIVE_PID="" +if [[ "$NO_SUDO" -ne 1 ]]; then + echo "shakedown: requesting sudo upfront (Phase 5 needs it)" + sudo -v + # Background keep-alive: refresh sudo credentials every 60s while the + # orchestrator is alive. macOS default sudo timestamp is 5 min, and Phase 4 + # on Intel takes ~8 min — without this, the user gets a second password + # prompt mid-run. + ( while true; do + sleep 60 + kill -0 "$$" 2>/dev/null || exit + sudo -n true 2>/dev/null || exit + done ) & + SUDO_KEEPALIVE_PID=$! +fi WORK=$(mktemp -d -t shakedown-run) -trap 'rm -rf "$WORK"' EXIT +trap 'if [[ -n "$SUDO_KEEPALIVE_PID" ]]; then kill "$SUDO_KEEPALIVE_PID" 2>/dev/null || true; fi; rm -rf "$WORK"' EXIT PREFLIGHT_TXT="$WORK/preflight.txt" INVENTORY_JSON="$WORK/inventory.json" @@ -154,9 +213,16 @@ echo "shakedown: Phase 2 — battery" echo "shakedown: Phase 4 — CPU variance (~6-10 min depending on chassis)" "$SCRIPT_DIR/cpu-variance.sh" > "$VARIANCE_JSON" -echo "shakedown: Phase 5 — sustained thermal load (~10 min, needs sudo)" -# shellcheck disable=SC2024 # the redirect target is a user-owned tempdir, not privileged. -sudo CHASSIS_CLASS="$CHASSIS_CLASS" "$SCRIPT_DIR/thermal-load.sh" > "$THERMAL_JSON" +if [[ "$NO_SUDO" -eq 1 ]]; then + echo "shakedown: Phase 5 — skipped (--no-sudo)" + cat > "$THERMAL_JSON" < "$THERMAL_JSON" +fi echo "shakedown: aggregating into canonical report" diff --git a/run b/run new file mode 100755 index 0000000..e220208 --- /dev/null +++ b/run @@ -0,0 +1,3 @@ +#!/bin/bash +# Convenience entry point — execs the orchestrator with all args forwarded. +exec "$(dirname "$0")/Verification/scripts/run-shakedown.sh" "$@" From f114c779c6cc00e02c0f643ac5e3f3e70695be1e Mon Sep 17 00:00:00 2001 From: Carl-W Date: Tue, 12 May 2026 09:22:37 +0200 Subject: [PATCH 05/10] =?UTF-8?q?shakedown:=20thermal=20=E2=80=94=20skip?= =?UTF-8?q?=20fan-ramp=20check=20when=20fans=20are=20pinned=20high?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "max-min < 200 RPM" ramp check was warning on runs where fans were already at high RPM throughout — either because the chassis was heat-soaked from Phase 4, or because the user has fan curves manually pinned (TG Pro, Macs Fan Control, repair shop tooling). Both cases mean cooling is engaged; the lack of *delta* doesn't indicate a defect. If min(fan_avgs) > 3000 RPM, the ramp check is skipped and a data-quality note records that fans were pinned high. Verdict isn't affected. --- Verification/scripts/thermal-load.sh | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Verification/scripts/thermal-load.sh b/Verification/scripts/thermal-load.sh index 88632c0..9f219d1 100755 --- a/Verification/scripts/thermal-load.sh +++ b/Verification/scripts/thermal-load.sh @@ -381,6 +381,15 @@ else: "no fan data captured by powermetrics (clamshell / sampler quirk?) — " "verify fan engagement manually" ) + elif min(fan_avgs) > 3000: + # Cooling was engaged throughout the run — high absolute RPM from the + # start means the chassis was heat-soaked from the preceding variance + # phase, or the user has fan curves manually set high. Either way the + # "didn't ramp" check (max-min < 200) would false-positive. + data_quality_notes.append( + f"fans pinned high throughout (min {min(fan_avgs):.0f} RPM) — " + f"ramp check skipped, cooling clearly engaged" + ) elif max(fan_avgs) - min(fan_avgs) < 200: if chassis == "desktop": fail_signals.append( From 814c736ba92b1c01c8c9a9ad8f60d06cc293b8f5 Mon Sep 17 00:00:00 2001 From: Carl-W Date: Tue, 12 May 2026 09:26:55 +0200 Subject: [PATCH 06/10] =?UTF-8?q?shakedown:=20thermal=20=E2=80=94=20fix=20?= =?UTF-8?q?Intel=20powermetrics=20parsing=20for=20Sonoma+?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Sonoma format differs substantially from older Intel macOS: Sonoma+: CPU Average frequency as fraction of nominal: 72.40% (1882.29 Mhz) Intel energy model derived package power (CPUs+GT+SA): 6.14W Legacy: CPU 0 frequency: 3192 MHz Package Power: 12.5 W The old regex expected the legacy format only, so on Sonoma p_freqs ended up empty, data_quality dropped to "few_samples", and the thermal verdict couldn't compute steady-state or cliff metrics. Carl's earlier Intel run hit exactly this — "no P-cluster frequency data" in the report. Added Sonoma-format regexes alongside the legacy ones; both run on every line, whichever matches wins. Also case-insensitive on "Mhz" since Sonoma uses lowercase 'h'. Made the data-quality message generic ("no CPU frequency data" instead of "no P-cluster") since the script handles both architectures. --- Verification/scripts/thermal-load.sh | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/Verification/scripts/thermal-load.sh b/Verification/scripts/thermal-load.sh index 9f219d1..0f64750 100755 --- a/Verification/scripts/thermal-load.sh +++ b/Verification/scripts/thermal-load.sh @@ -167,17 +167,26 @@ for blk in blocks: # Apple Silicon combined power m = re.search(r"Combined Power \(CPU \+ GPU \+ ANE\):\s+([\d.]+)\s*mW", line) if m: sd["combined_power_mw"] = float(m.group(1)) - # Intel: per-CPU frequency lines look like "CPU 0 frequency: 3192 MHz" - m = re.search(r"^\s*CPU\s+\d+\s+frequency:\s+(\d+)\s+MHz", line) + # Intel frequency — two formats across macOS versions: + # Sonoma+: "CPU Average frequency as fraction of nominal: 72.40% (1882.29 Mhz)" + # (the CPU number is on the preceding "CPU N duty cycles/s:" line) + # Legacy: "CPU 0 frequency: 3192 MHz" (one line per core) + # Case-insensitive on the Mhz/MHz spelling — Sonoma uses lowercase 'h'. + m = re.search(r"^\s*CPU Average frequency as fraction of nominal:\s+[\d.]+%\s*\(([\d.]+)\s*M[Hh]z\)", line) + if m: sd.setdefault("intel_cpu_freq_mhz", []).append(int(float(m.group(1)))) + m = re.search(r"^\s*CPU\s+\d+\s+frequency:\s+(\d+)\s+M[Hh]z", line) if m: sd.setdefault("intel_cpu_freq_mhz", []).append(int(m.group(1))) - # Intel: package and IA-cores power - # Intel powermetrics emits power in W on most macOS versions, in mW on a few. - # Try both; normalize to mW internally. - m = re.search(r"Package Power:\s+([\d.]+)\s*(W|mW)\b", line) + # Intel package power — two formats: + # Sonoma+: "Intel energy model derived package power (CPUs+GT+SA): 6.14W" + # Legacy: "Package Power: X W" + # Sonoma combines CPU+GT+SA into one number; legacy had separate IA / GT lines. + m = re.search(r"^\s*Intel energy model derived package power[^:]*:\s*([\d.]+)\s*(W|mW)\b", line) if m: sd["intel_package_power_mw"] = float(m.group(1)) * (1000.0 if m.group(2) == "W" else 1.0) - m = re.search(r"IA Cores Power:\s+([\d.]+)\s*(W|mW)\b", line) + m = re.search(r"^\s*Package Power:\s+([\d.]+)\s*(W|mW)\b", line) + if m: sd["intel_package_power_mw"] = float(m.group(1)) * (1000.0 if m.group(2) == "W" else 1.0) + m = re.search(r"^\s*IA Cores Power:\s+([\d.]+)\s*(W|mW)\b", line) if m: sd["intel_ia_cores_power_mw"] = float(m.group(1)) * (1000.0 if m.group(2) == "W" else 1.0) - m = re.search(r"GT Cores Power:\s+([\d.]+)\s*(W|mW)\b", line) + m = re.search(r"^\s*GT Cores Power:\s+([\d.]+)\s*(W|mW)\b", line) if m: sd["intel_gt_cores_power_mw"] = float(m.group(1)) * (1000.0 if m.group(2) == "W" else 1.0) if sd: samples.append(sd) @@ -280,7 +289,7 @@ elif len(samples) < expected_min_samples: ) if not p_freqs and data_quality == "ok": data_quality = "few_samples" - data_quality_notes.append("no P-cluster frequency data") + data_quality_notes.append("no CPU frequency data — powermetrics format may have changed") # Sanity: if powermetrics returned samples but the key metrics are entirely # absent (likely a macOS-version format change), surface it loudly. if samples and not cpu_temps: From 86d584b031633977fa96c999e972c7b76a120a8f Mon Sep 17 00:00:00 2001 From: Carl-W Date: Tue, 12 May 2026 09:32:06 +0200 Subject: [PATCH 07/10] shakedown: ignite animation before each phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-phase build-up animation using Unicode block-flame characters in red: ▁ → ▂ → ▃ → ▄ ▁ → ▅ ▂ → … → █ ▇ ▅ ▃, then the phase label in bold red prefixed with ██. ~700 ms total — enough to feel like a transition, short enough not to pad the run. Falls back to a plain echo when stderr isn't a TTY so CI / piped logs stay clean. No emojis in the script. --- Verification/scripts/run-shakedown.sh | 33 ++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/Verification/scripts/run-shakedown.sh b/Verification/scripts/run-shakedown.sh index 7e10e48..327d9d5 100755 --- a/Verification/scripts/run-shakedown.sh +++ b/Verification/scripts/run-shakedown.sh @@ -111,6 +111,27 @@ PYEOF fi export CHASSIS_CLASS +ignite() { + # Build-up flame animation before a phase. ~700 ms total — short enough that + # it doesn't pad the run, long enough to give the eye a transition. Falls + # back to a plain echo when stderr isn't a TTY (CI, piped logs). + if [[ ! -t 2 ]]; then + echo "shakedown: $*" >&2 + return + fi + local label="$*" + local R=$'\033[91m' + local B=$'\033[1;31m' + local X=$'\033[0m' + local cl=$'\r\033[K' + local frames=('▁' '▂' '▃' '▄ ▁' '▅ ▂' '▆ ▃ ▁' '▇ ▄ ▂' '█ ▅ ▃ ▁' '▇ ▆ ▄ ▂' '█ ▇ ▅ ▃') + for f in "${frames[@]}"; do + printf '%s%s%s%s' "$cl" "$R" "$f" "$X" >&2 + sleep 0.07 + done + printf '%s%s██ %s%s\n' "$cl" "$B" "$label" "$X" >&2 +} + banner() { if [[ ! -t 2 ]]; then return; fi local Y=$'\033[93m' # bright yellow (wisps) @@ -192,7 +213,7 @@ BATTERY_JSON="$WORK/battery.json" VARIANCE_JSON="$WORK/variance.json" THERMAL_JSON="$WORK/thermal.json" -echo "shakedown: Phase 0 — preflight (1 min)" +ignite "Phase 0 — preflight" { echo "=== uptime ===" uptime @@ -204,22 +225,22 @@ echo "shakedown: Phase 0 — preflight (1 min)" networksetup -getairportpower en0 2>/dev/null || echo "(no en0)" } > "$PREFLIGHT_TXT" 2>&1 -echo "shakedown: Phase 1 — inventory" +ignite "Phase 1 — inventory" "$SCRIPT_DIR/inventory.sh" > "$INVENTORY_JSON" -echo "shakedown: Phase 2 — battery" +ignite "Phase 2 — battery" "$SCRIPT_DIR/battery.sh" > "$BATTERY_JSON" -echo "shakedown: Phase 4 — CPU variance (~6-10 min depending on chassis)" +ignite "Phase 4 — CPU variance (~6-10 min depending on chassis)" "$SCRIPT_DIR/cpu-variance.sh" > "$VARIANCE_JSON" if [[ "$NO_SUDO" -eq 1 ]]; then - echo "shakedown: Phase 5 — skipped (--no-sudo)" + ignite "Phase 5 — skipped (--no-sudo)" cat > "$THERMAL_JSON" < "$THERMAL_JSON" fi From ed715abba8800d3e40847b6adda5b0be6f60de82 Mon Sep 17 00:00:00 2001 From: Carl-W Date: Tue, 12 May 2026 09:33:32 +0200 Subject: [PATCH 08/10] shakedown: heartbeat sparks during silent phases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background ticker that prints a varied spark line (yellow/orange/red gradient, sparse ASCII glyphs: * · ✦) every 10–17 seconds during Phase 4 and Phase 5. Phase 4 warmup is 180s with no sub-script output, and Phase 5 is a full 10-min sustained load with no progress prints — the heartbeat keeps the screen alive without garbling the sub-script's own output (no \r overwrites; each spark gets its own line). Killed cleanly via stop_heartbeat() after each phase; the EXIT trap also catches any leak on abort or unexpected exit. Suppressed when stderr isn't a TTY. --- Verification/scripts/run-shakedown.sh | 49 ++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/Verification/scripts/run-shakedown.sh b/Verification/scripts/run-shakedown.sh index 327d9d5..d8d5ad2 100755 --- a/Verification/scripts/run-shakedown.sh +++ b/Verification/scripts/run-shakedown.sh @@ -132,6 +132,48 @@ ignite() { printf '%s%s██ %s%s\n' "$cl" "$B" "$label" "$X" >&2 } +heartbeat() { + # Background sparks during silent stretches (Phase 4 warmup, Phase 5 sustained + # load). Sparse and varied, prints to stderr without \r so it doesn't fight + # with the sub-script's own progress prints. Suppressed when not a TTY. + if [[ ! -t 2 ]]; then return; fi + local Y=$'\033[93m' + local O=$'\033[33m' + local R=$'\033[91m' + local X=$'\033[0m' + local sparks=( + "${Y} * . ✦${X}" + "${O} · * ${X}" + "${R} ✦ · ${X}" + "${Y} · . * ${X}" + "${O} · · ${X}" + "${R} * ✦ ·${X}" + "${Y} ✦ · * ${X}" + "${O} · . ${X}" + ) + local i=0 + while true; do + sleep $(( 10 + RANDOM % 8 )) # 10–17 seconds between sparks + kill -0 "$$" 2>/dev/null || exit + printf ' %s\n' "${sparks[i % ${#sparks[@]}]}" >&2 + i=$((i + 1)) + done +} + +start_heartbeat() { + if [[ ! -t 2 ]]; then return; fi + heartbeat & + HEARTBEAT_PID=$! +} + +stop_heartbeat() { + if [[ -n "${HEARTBEAT_PID:-}" ]]; then + kill "$HEARTBEAT_PID" 2>/dev/null || true + wait "$HEARTBEAT_PID" 2>/dev/null || true + HEARTBEAT_PID="" + fi +} + banner() { if [[ ! -t 2 ]]; then return; fi local Y=$'\033[93m' # bright yellow (wisps) @@ -205,7 +247,8 @@ if [[ "$NO_SUDO" -ne 1 ]]; then fi WORK=$(mktemp -d -t shakedown-run) -trap 'if [[ -n "$SUDO_KEEPALIVE_PID" ]]; then kill "$SUDO_KEEPALIVE_PID" 2>/dev/null || true; fi; rm -rf "$WORK"' EXIT +HEARTBEAT_PID="" +trap 'if [[ -n "$HEARTBEAT_PID" ]]; then kill "$HEARTBEAT_PID" 2>/dev/null || true; fi; if [[ -n "$SUDO_KEEPALIVE_PID" ]]; then kill "$SUDO_KEEPALIVE_PID" 2>/dev/null || true; fi; rm -rf "$WORK"' EXIT PREFLIGHT_TXT="$WORK/preflight.txt" INVENTORY_JSON="$WORK/inventory.json" @@ -232,7 +275,9 @@ ignite "Phase 2 — battery" "$SCRIPT_DIR/battery.sh" > "$BATTERY_JSON" ignite "Phase 4 — CPU variance (~6-10 min depending on chassis)" +start_heartbeat "$SCRIPT_DIR/cpu-variance.sh" > "$VARIANCE_JSON" +stop_heartbeat if [[ "$NO_SUDO" -eq 1 ]]; then ignite "Phase 5 — skipped (--no-sudo)" @@ -241,8 +286,10 @@ if [[ "$NO_SUDO" -eq 1 ]]; then EOF else ignite "Phase 5 — sustained thermal load (~10 min, needs sudo)" + start_heartbeat # shellcheck disable=SC2024 # the redirect target is a user-owned tempdir, not privileged. sudo CHASSIS_CLASS="$CHASSIS_CLASS" "$SCRIPT_DIR/thermal-load.sh" > "$THERMAL_JSON" + stop_heartbeat fi echo "shakedown: aggregating into canonical report" From 9c1d181eab8b97d6d52510bb430feaeb3380c9c5 Mon Sep 17 00:00:00 2001 From: Carl-W Date: Tue, 12 May 2026 09:42:08 +0200 Subject: [PATCH 09/10] readme: drop agent angle, lead with ./run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orchestrator runs end-to-end without an agent — sudo is interactive and the y/N prompt blocks, so agents can't actually drive ./run anyway. - Tagline drops "Claude-Code-driven" and "Apple Silicon" (we run on Intel too). - Quick start leads with ./run, removes the Claude Code install step. - Specifying your target switches the example commands from claude "…" to ./run. - "Optional: agent assistance" section removed entirely. - "Manual usage (without Claude Code)" renamed to "Running individual scripts (advanced)" and reframed as the rerun-one-phase escape hatch, not the fallback for users without an agent. - "Submit a calibration report" trimmed — Quick start now covers the orchestrator + filename + dual-copy split, so the section just describes the PR flow. - Repo layout adds ./run, run-shakedown.sh, mbp-16-intel-2019.json, Reports/local/, and Reports/submissions/. AGENTS.md and CLAUDE.md still exist (cross-tool convention, harmless) but are no longer promoted. --- README.md | 113 ++++++++++++++++++++---------------------------------- 1 file changed, 41 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 3cce0d5..e4cf862 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ > Verify a new Mac before your return window closes. -A Claude-Code-driven verification harness for new Apple Silicon Macs. Designed for the case where you can't easily return the unit — bought abroad, narrow window, expensive config, or you just don't want to discover a defect three months from now. +A verification harness for new Macs (Apple Silicon and Intel). Designed for the case where you can't easily return the unit — bought abroad, narrow window, expensive config, or you just don't want to discover a defect three months from now. -The agent runs a sequence of deterministic shell scripts (system inventory, battery health, CPU variance benchmark, sustained thermal load) and walks you through manual physical checks (display dead-pixel test, hinge / keyboard / speaker / port inspection, Apple Diagnostics). Outputs a structured PASS/FAIL report with cited evidence. +A single command runs the automated phases (system inventory, battery health, CPU variance benchmark, sustained thermal load) end-to-end. A separate runbook walks you through the manual phases (display dead-pixel test, hinge / keyboard / speaker / port inspection, Apple Diagnostics). > **Why "shakedown"?** Borrowed from engineering: a *shakedown run* is the first-run stress test of new machinery before it's commissioned. Same idea, applied to your new Mac. @@ -20,47 +20,32 @@ Shakedown is the procedure for catching those. ## Quick start -On the new Mac, with [Claude Code](https://docs.claude.com/claude-code) (the recommended runtime — see [Supported agents](#supported-agents) for alternatives): - -1. **Install Xcode Command Line Tools** (provides `git`). 5–10 min, one-time. A GUI dialog will pop up: +1. **Install Xcode Command Line Tools** (provides `git`, 5–10 min one-time; a GUI dialog pops up): ```bash xcode-select -p >/dev/null 2>&1 || xcode-select --install ``` -2. **Install Claude Code.** See [docs.claude.com/claude-code](https://docs.claude.com/claude-code) for the current install method: - - ```bash - npm install -g @anthropic-ai/claude-code - ``` - -3. **Clone and run.** +2. **Clone and run.** ```bash git clone https://github.com/ugglr/mac-shakedown ~/mac-shakedown && cd ~/mac-shakedown - claude "run the QA against target mbp-16-m5-max-64" + ./run --target mbp-16-m5-max-64 ``` -The agent walks the [runbook](Verification/Runbook.md) end to end (~45 min on a MacBook Pro, ~25 min on a fanless MacBook Air, plus an optional 30 min idle-drain test). Outputs `Reports/.json` and `Reports/.md`. - -> Keep the charger plugged in for all phases except the optional idle-drain test (Phase 9). +Or without a preset (auto-detects chassis class from `system_profiler` — fine for Macs that don't have a target preset yet): -## Supported agents +```bash +./run +``` -Shakedown's agent instructions live in [`AGENTS.md`](AGENTS.md), following the cross-tool convention so any sufficiently capable agent can run it. The harness needs an agent that can: run shell commands, read files, ask the user yes/no questions, and sit through ~16–20 minutes of blocking benchmarks. +The orchestrator runs the four automated phases (preflight → inventory → battery → CPU variance → thermal load), asks for sudo once upfront (Phase 5 needs `powermetrics`), and writes a SCHEMA-compliant report to `Reports/local/` plus a sanitized PR-able copy to `Reports/submissions/`. Runtime ~18 min on Intel, ~25 min on Air, ~45 min on MacBook Pro. -| Agent | Status | Notes | -|---|---|---| -| **Claude Code** | ✅ reference runtime | `CLAUDE.md` points at `AGENTS.md` so it auto-loads. Use the [Quick start](#quick-start) above. | -| Cursor | 🟡 unverified — should work | In Composer / Agent mode, attach `AGENTS.md` to the prompt: *"Follow AGENTS.md and run the QA against target mbp-16-m5-max-64."* | -| Cline / Roo Code | 🟡 unverified — should work | Point at `AGENTS.md` in the system prompt. Same invocation as Cursor. | -| Aider | 🟡 unverified — should work | `aider --read AGENTS.md` then ask it to run the QA. | -| OpenAI Codex CLI | 🟡 unverified — should work | Recognizes `AGENTS.md` natively. Just `codex "run the QA against target mbp-16-m5-max-64"`. | -| Other | — | Anything that can run a shell + read files + chat with the user. Tell it: *"Read `AGENTS.md`. Run the QA against my Mac per the procedure described there."* | +`./run --no-sudo` skips the 10-min thermal phase (the only phase that needs sudo) for a half-runtime no-password variance-only pass. -If you've run Shakedown end-to-end with a non-Claude-Code agent, please open a PR updating the table to ✅. If you hit friction, open an issue with the agent name and what tripped — happy to clarify the runbook for any compatible runtime. +> Keep the charger plugged in for all phases. -**No agent at all?** The scripts run standalone — no LLM required. Read [`Verification/Runbook.md`](Verification/Runbook.md), execute the scripts in order, answer the manual-check questions to yourself, and write the report by hand. The agent flow is convenience, not a hard dependency. +**For the manual phases** (display test, hinge / keyboard / speaker / port inspection, Apple Diagnostics, optional 30-min idle drain), follow [`Verification/Runbook.md`](Verification/Runbook.md) phases 6–9 by hand. ## What a run looks like @@ -77,52 +62,55 @@ See [`examples/sample-report-illustrative/`](examples/sample-report-illustrative | 4 | **CPU performance variance** | 5 s burst + chassis-class-aware warmup (300 s on Pro, 60 s on Air) + 5 × 60 s timed iterations, parallel SHA-256 → `cpu-variance.sh` | | 5 | Sustained thermal load (chassis-class-aware thresholds) | 10 min continuous + `powermetrics` sampling → `thermal-load.sh` | | 6 | Display visual inspection | fullscreen color cycle in Safari → `display-test.sh` | -| 7 | Manual physical inspection | agent prompts: hinge, keyboard, speakers, ports, Touch ID, etc. | +| 7 | Manual physical inspection | hinge, keyboard, speakers, ports, Touch ID, etc. (runbook checklist) | | 8 | Apple Diagnostics | reboot + Cmd-D | | 9 | Optional idle drain | 30 min sleep, measure %/30 min | ## Specifying your target -Three ways: +Two ways: 1. **Use a preset.** `targets/*.json` has presets for common SKUs: - [`mbp-16-m5-max-64`](targets/mbp-16-m5-max-64.json) - [`mbp-14-m5-pro-24`](targets/mbp-14-m5-pro-24.json) - [`macbook-air-m5-16`](targets/macbook-air-m5-16.json) + - [`mbp-16-intel-2019`](targets/mbp-16-intel-2019.json) ```bash - claude "run QA against target mbp-16-m5-max-64" + ./run --target mbp-16-m5-max-64 ``` -2. **Tell the agent.** No preset, just describe what you bought: + Hard-fails if the chip / RAM don't match the preset — useful when verifying you got the SKU you paid for. + +2. **No target.** Auto-detects chassis class, skips the SKU asserts, still runs all the variance / thermal / battery checks: ```bash - claude "run QA — I bought a 14-inch M5 Max with 36 GB" + ./run ``` -3. **No target.** The agent runs the verification and reports what it finds, without asserting against any spec. + Use this for Macs that don't have a preset yet, or existing units you're self-testing rather than verifying as new. - ```bash - claude "run QA" - ``` +(Don't see your SKU? `targets/README.md` has the schema — open a PR adding a preset.) ## Repo layout ``` mac-shakedown/ ├── README.md -├── AGENTS.md # agent operating manual (cross-tool convention) -├── CLAUDE.md # one-line pointer at AGENTS.md (Claude Code auto-loader) ├── CONTRIBUTING.md ├── CHANGELOG.md ├── SECURITY.md ├── LICENSE +├── run # convenience entry point — execs the orchestrator +├── AGENTS.md # cross-tool agent convention (optional) +├── CLAUDE.md # Claude Code auto-loader, points at AGENTS.md ├── Shakedown Brain.md # Obsidian map-of-content (optional, for vault users) ├── .github/ # issue + PR templates, CI lint workflow ├── Verification/ # generation-agnostic test machinery │ ├── Runbook.md # the procedure │ ├── Pass-Fail Criteria.md # thresholds (parameterized by chassis class) │ └── scripts/ +│ ├── run-shakedown.sh # the orchestrator (`./run` execs this) │ ├── inventory.sh # system_profiler + sysctl → JSON │ ├── battery.sh # ioreg battery health → JSON │ ├── cpu-variance.sh # burst + warmup + 5×60s timed iters → JSON @@ -132,7 +120,8 @@ mac-shakedown/ │ ├── README.md │ ├── mbp-16-m5-max-64.json │ ├── mbp-14-m5-pro-24.json -│ └── macbook-air-m5-16.json +│ ├── macbook-air-m5-16.json +│ └── mbp-16-intel-2019.json ├── examples/ # generation-specific calibrations + sample reports │ ├── m5-2026/ # the 2026 M5 generation defect landscape │ │ ├── README.md @@ -140,17 +129,19 @@ mac-shakedown/ │ │ ├── Issues/ │ │ └── Sources.md │ └── sample-report-illustrative/ # what a real run looks like (illustrative, not from a real unit) -└── Reports/ # output artifacts (gitignored) - └── SCHEMA.md # JSON report schema (v1.0) +└── Reports/ # output artifacts + ├── SCHEMA.md # JSON report schema (v1.0) + ├── local/ # full output, gitignored + └── submissions/ # sanitized, PR-able copies ``` ## Adding a new generation When a new chip line ships, copy `examples/m5-2026/` to `examples/-/`, document the issues there, and add a target preset pointing to it. See [CONTRIBUTING.md](CONTRIBUTING.md#adding-a-generation-calibration). -## Manual usage (without Claude Code) +## Running individual scripts (advanced) -You can run the scripts directly. Set `CHASSIS_CLASS` to match your Mac (default is `active-cooled-pro` — fine for an Apple Silicon MBP, wrong for an Air or Intel Mac): +`./run` orchestrates the five script-driven phases. If you want to rerun a single phase — say, to confirm a borderline variance warn without redoing the whole 18-min pass — call the scripts directly: ```bash export CHASSIS_CLASS=active-cooled-pro # or fanless | desktop | intel-laptop | intel-desktop @@ -162,41 +153,19 @@ sudo CHASSIS_CLASS="$CHASSIS_CLASS" ./Verification/scripts/thermal-load.sh > Rep ./Verification/scripts/display-test.sh ``` -The inline `sudo CHASSIS_CLASS=...` form preserves the env var across the privilege boundary regardless of sudoers `env_keep` config (otherwise `thermal-load.sh` falls back to `active-cooled-pro` defaults). Then read the values against [Pass-Fail Criteria](Verification/Pass-Fail%20Criteria.md). The agent flow is recommended though — most of the value is in the manual prompts and the cross-referencing against calibration notes. +The inline `sudo CHASSIS_CLASS=...` form preserves the env var across the privilege boundary regardless of sudoers `env_keep` config. Read the values against [Pass-Fail Criteria](Verification/Pass-Fail%20Criteria.md). ## Submit a calibration report -The v0.1 thresholds need real-world data to calibrate. If you ran the harness — PASS, WARN, or FAIL — please consider submitting your report. **Especially valuable: known-good machines from someone you trust**, which is what the methodology currently lacks. - -There's no hosted backend; submissions land via PR. - -```bash -cd ~/mac-shakedown -./run --target mbp-16-m5-max-64 -``` - -Or without a preset (for Macs that don't have one yet, or existing units you're self-testing rather than verifying as new): - -```bash -./run -``` - -Without `--target`, chassis class is auto-detected from `system_profiler` (override with `CHASSIS_CLASS=intel-laptop ./run`), and the inventory + "is this factory-fresh?" battery asserts are skipped — real-degradation signals (battery capacity < 95%, abnormal condition, SSD SMART status) still fail the run. - -`./run --no-sudo` skips Phase 5 (the only phase that needs sudo), so you can run a no-password variance-only pass — about half the runtime. - -The orchestrator runs the auto-runnable phases (preflight → inventory → battery → CPU variance → thermal load), aggregates into a SCHEMA-compliant JSON, and writes two copies: - -- `Reports/local/.json` — full output, **gitignored** (keeps `_raw_*` debug fields) -- `Reports/submissions/.json` — sanitized for PR submission +The v0.1 thresholds need real-world data to calibrate. If you ran the harness — PASS, WARN, or FAIL — please consider submitting your report. **Known-good machines from someone you trust are the most valuable submissions**, since that's what the methodology currently lacks. -To submit: +`./run` already writes the sanitized copy to `Reports/submissions/.json`. To submit it: -1. Skim the submission JSON for any leftover PII. +1. Skim the JSON for any leftover PII (free-form notes, store name, etc.). 2. `git checkout -b submit- && git add Reports/submissions/.json && git commit -m "submission: "` -3. Open a PR. CI runs the submission audit (rejects `_raw_*` leakage, plaintext serials, malformed schema, off-pattern filenames). +3. Open a PR. CI runs the submission audit and rejects `_raw_*` leakage, plaintext serials, malformed schema, or off-pattern filenames. -The manual phases (display, physical inspection, Apple Diagnostics, idle drain) are emitted as `skipped` placeholders. If you ran any of them, hand-edit `Reports/local/.json`, re-sanitize, and overwrite the submission copy before opening the PR. +The manual phases (display, physical inspection, Apple Diagnostics, idle drain) land as `skipped` placeholders. If you ran any of them, hand-edit `Reports/local/.json` to fill in the results, re-sanitize, and overwrite the submission copy before the PR. ## Roadmap From da0fd5a9c5da2c8bb34a0cef22c42bbc5abc2650 Mon Sep 17 00:00:00 2001 From: Carl-W Date: Tue, 12 May 2026 10:03:40 +0200 Subject: [PATCH 10/10] remove the agent angle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit You (the agent that built this) can't actually run ./run end-to-end — sudo is interactive and the y/N prompt blocks. Same goes for every other agent. The "Claude-Code-driven harness" framing was historical; the script does the actual work, the runbook covers the manual checks, and there's no load-bearing role left for an LLM. Deleted: - AGENTS.md - CLAUDE.md Rewrote agent references to point at ./run, the orchestrator, or "step through these checks": - Verification/Runbook.md (intro framing, Phase 6/7/8 walkthroughs) - targets/README.md, CONTRIBUTING.md (claude "..." → ./run) - Reports/SCHEMA.md, SECURITY.md (agent strips → orchestrator strips) - examples/m5-2026/README.md, Shakedown Brain.md - targets/mbp-16-intel-2019.json (_note field) - .github/ISSUE_TEMPLATE/bug-report.yml (component dropdown) - Verification/scripts/cpu-variance.sh (comments) - examples/sample-report-illustrative/*.md (footer) - README.md repo-layout tree (drop the deleted entries) CHANGELOG entries about AGENTS.md from past waves are left in place — those describe history, not current state. --- .github/ISSUE_TEMPLATE/bug-report.yml | 3 +- AGENTS.md | 85 ------------------- CHANGELOG.md | 4 + CLAUDE.md | 5 -- CONTRIBUTING.md | 6 +- README.md | 2 - Reports/SCHEMA.md | 12 +-- SECURITY.md | 6 +- Shakedown Brain.md | 6 +- Verification/Runbook.md | 29 ++----- Verification/scripts/cpu-variance.sh | 4 +- examples/m5-2026/README.md | 11 +-- .../2026-04-30T14-30-00-mbp-16-m5-max-64.md | 2 +- targets/README.md | 8 +- targets/mbp-16-intel-2019.json | 2 +- 15 files changed, 41 insertions(+), 144 deletions(-) delete mode 100644 AGENTS.md delete mode 100644 CLAUDE.md diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 3e51aa0..6facebe 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -18,7 +18,8 @@ body: - cpu-variance.sh - thermal-load.sh - display-test.sh - - Runbook / agent flow + - run-shakedown.sh (orchestrator) + - Runbook - Target preset loading - Report rendering (JSON/MD) - Other diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 1b812fc..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,85 +0,0 @@ -# Shakedown — Agent Operating Manual - -You are operating inside **Shakedown**, an open-source Mac verification harness. The user is verifying a new Apple Silicon Mac (typically right after purchase, often before they can easily return it). Your job is to run the runbook against the unit and produce a structured PASS/FAIL report with evidence. - -The name is borrowed from engineering — a *shakedown run* is the first-run stress test of new machinery before commissioning. Same concept here. - -> This file follows the [`AGENTS.md`](https://agents.md) convention so any agent that supports it can pick up these instructions. **Claude Code is the reference / recommended runtime** — it's what Shakedown is primarily developed against — but Cursor, Cline, Aider, OpenAI Codex CLI, etc. should all work as long as they can run shell commands, read files, and ask the user yes/no questions. - -## When the user asks you to "run QA" / "verify this Mac" / similar - -1. **Resolve the target.** Three cases: - - *User specified a preset* (e.g. `--target mbp-16-m5-max-64`, or "against target X"): load `targets/.json` and use those assertions. - - *User described the unit verbally* (e.g. "I bought a 14-inch M5 Max with 36 GB"): construct an in-memory target with those fields. Don't write a preset file. - - *User didn't specify*: ask once — "What chip, RAM, and chassis size did you buy?" Then use those. - - *User says "just verify it"*: run with no assertions, report the actual unit specs. - -2. **Read the calibration.** If the target has `calibration_dir` (e.g. `examples/m5-2026`), read its ` Quality Issues.md` overview before starting. This gives you the defect landscape to cross-reference findings against. - -3. **Walk the runbook.** [`Verification/Runbook.md`](Verification/Runbook.md) end to end. Don't skip phases without explicit user permission. One phase at a time, surface findings as you go. - -4. **Write evidence.** Final outputs: - - `Reports/.json` — canonical machine-readable report (schema in [`Reports/SCHEMA.md`](Reports/SCHEMA.md)) - - `Reports/.md` — human-readable render of the JSON - -## Operating principles - -1. **Be the harness.** Run the scripts in `Verification/scripts/`, capture stdout, parse the JSON, write evidence. Don't reimplement what the scripts already do. - -2. **Lead the manual checks.** For physical inspection (hinge creak, palmrest flex, dead pixels, every keyboard key, listen for speaker crackle), prompt the user with a clear, single yes/no question and record the answer. Don't assume; ask. One question at a time when possible. - -3. **Stop on critical mismatch.** If the unit doesn't match the target's `chip_pattern`, `memory_gb`, or `model_must_include`, surface it immediately and ask whether to continue. There's no point benchmarking the wrong unit. - -4. **Cite the calibration.** When a finding maps to a documented issue (e.g. high CPU variance → bad-batch issue), link the relevant note in the calibration's `Issues/` folder. Don't paraphrase from memory; quote the specific note. - -5. **Pass `CHASSIS_CLASS` to the load tests.** The variance and thermal scripts both accept `CHASSIS_CLASS={fanless|active-cooled-pro|desktop|intel-laptop|intel-desktop}` as an env var; pull it from the target preset's `thermal_chassis_class` field and export before invoking. This sets warmup duration (Phase 4) and thermal thresholds (Phase 5). Default if unset: `active-cooled-pro`. - -6. **Sudo once, early.** `thermal-load.sh` needs sudo for `powermetrics`. Prompt the user once at the start of Phase 5 (`sudo -v`), and use `sudo CHASSIS_CLASS="$CHASSIS_CLASS" ./Verification/scripts/thermal-load.sh` so the `CHASSIS_CLASS` env var crosses the privilege boundary regardless of sudoers `env_keep` config. Don't ask for sudo again mid-run. - -7. **Clear PASS / FAIL.** Final line of the report and final line in chat: `RESULT: PASS` or `RESULT: FAIL — `. The user is often on a clock. Don't bury the lede. - -8. **Long-running phases.** Phase 4 (~10 min on Pro / ~6 min on Air) and Phase 5 (~10 min) are blocking. Run in foreground, stream stderr progress, parse the final JSON. Together they're ~16–20 min of continuous CPU load — well past thermal saturation, which is the point. - -9. **No cooldown between Phase 4 and Phase 5.** Variance test ends with a hot chassis, which is the correct starting state for the sustained thermal test. Run them back-to-back. - -10. **System must be quiet before Phase 4.** Background CPU activity steals P-cores from the benchmark and produces fake variance. Before launching `cpu-variance.sh`, run `uptime` and `top -l 1 -n 5 -o cpu | tail -7`. If load average is high or any non-system process is using > 5% CPU, ask the user to close it. On a fresh out-of-box unit this is automatic. - -11. **Phase 4 rerun-on-warn (decision tree).** A single warn could be background noise; act on the rerun: - - First run `pass` → record and continue. - - First run `warn` → rerun once. Then: - - Second run `pass` → record as pass; mention the original warn in the report's notes. - - Second run `warn` → record as **fail** (compound evidence across runs). - - Second run `fail` → record as **fail**. - - First run `fail` → record as fail; do not rerun (a single fail is signal enough). - No third run. Two warns is the limit; the cost of running a third can mask thermal-paste defects that worsen with each iteration. - -12. **Trust the script's data_quality field.** `thermal-load.sh` emits `data_quality: "no_samples" | "few_samples" | "ok"`. `no_samples` is automatically `verdict: "fail"` regardless of other metrics — don't override it just because no temp/freq numbers came through. - -13. **Phase 8 requires a reboot.** Apple Diagnostics needs the user to power off and hold the power button. Before the reboot, **write a partial JSON+MD report** so progress isn't lost. After reboot, the user resumes the agent (e.g. `claude continue` for Claude Code, or however your runtime resumes a session) and tells you the diagnostic result; you append it and finalize. - -14. **Submission-safe by default — but verify, don't assume.** The JSON report has `submission_safe: true` and a hashed serial. Before submitting to the future aggregator, **explicitly check**: - - `inventory.json.summary.serial_number` is absent (only `serial_hash` is present). If `serial_number` is there, the user ran with `INCLUDE_PLAINTEXT_SERIAL=1` — set `submission_safe: false`. - - `battery.json.battery_serial` is absent (only `battery_serial_hash`). - - `_raw_*` blocks are stripped from the canonical submission JSON (they may contain paired Bluetooth IDs, Wi-Fi SSIDs, USB device serials). - - Free-form user notes don't contain identifying info (store name, employee name, etc.). If they do, set `submission_safe: false` and warn the user. - - The hash is for **deduplication, not anonymization** — Apple's serial number space has limited entropy, so a determined aggregator could rainbow-table the original. Treat the hashing as obfuscation; treat `submission_safe: true` as "no plaintext PII," not "untraceable." - -## Thermal chassis class — what each setting does - -Set on the target via `thermal_chassis_class`. Pass through to scripts as `CHASSIS_CLASS` env var. - -- **`fanless`** (Apple Silicon MacBook Air): expect aggressive throttling under sustained load by design. `cpu-variance.sh` uses 60 s warmup. `thermal-load.sh` thresholds: steady-state ≥ 50% of peak (don't fail on throttle alone), no fan-ramp expectation. -- **`active-cooled-pro`** (Apple Silicon MacBook Pro 14"/16"): standard thresholds. `cpu-variance.sh` uses 300 s warmup (16" saturation is 5–8 min). `thermal-load.sh` thresholds: steady-state ≥ 70% of peak, fan ramp expected. -- **`desktop`** (Apple Silicon Mac mini / Studio / iMac): strictest. `cpu-variance.sh` uses 180 s warmup. `thermal-load.sh` thresholds: steady-state ≥ 90% of peak; on `desktop`, **fan-doesn't-engage is a fail** (not warn). Throttling on a desktop is a real defect. -- **`intel-laptop`** (Intel MacBook Pro / Air): looser thresholds — Intel laptops throttle hard by design. 180 s warmup. Steady-state ≥ 50% of peak. `thermal-load.sh` parses Intel-format powermetrics output (per-CPU frequency, Package Power, IA Cores Power). -- **`intel-desktop`** (Intel iMac / Mac mini): tighter than `intel-laptop` but looser than Apple Silicon `desktop`. 180 s warmup. Steady-state ≥ 75% of peak. - -If `thermal_chassis_class` isn't set, default to `active-cooled-pro`. Full threshold tables live in [Pass-Fail Criteria](Verification/Pass-Fail%20Criteria.md). - -## Background reading - -- [`examples/m5-2026/M5 Quality Issues.md`](examples/m5-2026/M5%20Quality%20Issues.md) — example calibration / defect landscape for the 2026 M5 generation -- [`Verification/Runbook.md`](Verification/Runbook.md) — the procedure -- [`Verification/Pass-Fail Criteria.md`](Verification/Pass-Fail%20Criteria.md) — thresholds -- [`Reports/SCHEMA.md`](Reports/SCHEMA.md) — JSON report schema diff --git a/CHANGELOG.md b/CHANGELOG.md index 73c251e..e31954b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project are documented here. Format follows [Keep a ## [Unreleased] +### Removed + +- **`AGENTS.md` and `CLAUDE.md`** — the orchestrator (`./run`) handles all the automated phases end-to-end without an LLM, sudo and the y/N confirmation prompt mean an agent can't actually drive `./run` anyway, and the manual phases (display test, physical inspection, Apple Diagnostics, idle drain) are checklist work that a runbook covers directly. The cross-tool agent convention added churn without enabling anything. All agent framing dropped from README, Runbook, CONTRIBUTING, SECURITY, Reports/SCHEMA, targets/README, examples/m5-2026/README, Shakedown Brain, bug-report template, and cpu-variance.sh comments. + ### Added — calibration submission workflow - **`run-shakedown.sh` orchestrator** — wraps the four auto-runnable phases (inventory, battery, CPU variance, thermal load) end-to-end, prompts sudo upfront, aggregates into a SCHEMA-compliant JSON, and emits a sanitized submission copy alongside the full local copy. One command instead of five-plus, with the predictable `{YYYY-MM-DD}-{preset}-{hash4}.json` filename convention. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 8b9d325..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,5 +0,0 @@ -# Claude Code - -Shakedown's agent operating manual lives in [`AGENTS.md`](AGENTS.md), following the cross-tool convention. Claude Code is the reference runtime for Shakedown — this file exists so Claude Code's auto-loader picks up the instructions. - -When operating, follow [`AGENTS.md`](AGENTS.md). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8352e9e..26554c3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,9 +7,9 @@ Thanks for the interest. Shakedown grows in two directions: ## Adding a target preset -A target preset lets users run `claude "run QA --target "` instead of typing chip / RAM / chassis each time. They live in `targets/*.json`. +A target preset lets users run `./run --target ` to assert the unit matches a specific SKU. They live in `targets/*.json`. -**Schema** (minimal — agent fills in from the Mac's own report for everything else): +**Schema** (minimal — the unit's own `system_profiler` output fills in everything else): ```json { @@ -28,7 +28,7 @@ A target preset lets users run `claude "run QA --target "` instead of typi | `chip_pattern` | Substring asserted against `system_profiler`'s `chip_type` | | `memory_gb` | Asserted against `sysctl hw.memsize` | | `model_must_include` | Substring asserted against `machine_model` (catches chassis size) | -| `calibration_dir` | Path to the `examples//` notes the agent should reference | +| `calibration_dir` | Path to the `examples//` notes for failure-analysis cross-reference | | `thermal_chassis_class` | One of `fanless`, `active-cooled-pro`, `desktop`, `intel-laptop`, `intel-desktop` — sets thermal thresholds and Phase 4 warmup duration. See [`targets/README.md`](targets/README.md) for the per-class definition. | Open a PR adding the JSON. Match an existing one for style. diff --git a/README.md b/README.md index e4cf862..c3bfbca 100644 --- a/README.md +++ b/README.md @@ -102,8 +102,6 @@ mac-shakedown/ ├── SECURITY.md ├── LICENSE ├── run # convenience entry point — execs the orchestrator -├── AGENTS.md # cross-tool agent convention (optional) -├── CLAUDE.md # Claude Code auto-loader, points at AGENTS.md ├── Shakedown Brain.md # Obsidian map-of-content (optional, for vault users) ├── .github/ # issue + PR templates, CI lint workflow ├── Verification/ # generation-agnostic test machinery diff --git a/Reports/SCHEMA.md b/Reports/SCHEMA.md index e34e0f7..07f4d51 100644 --- a/Reports/SCHEMA.md +++ b/Reports/SCHEMA.md @@ -19,7 +19,7 @@ Every QA run produces two artifacts in `Reports/`: | `phases` | object | yes | One key per phase (see Phase shape). | | `result` | string | yes | `"PASS"` / `"FAIL"`. | | `result_reason` | string | yes | One-line summary. | -| `submission_safe` | bool | yes | Agent's assertion that no PII is present. | +| `submission_safe` | bool | yes | Orchestrator's assertion that no PII is present. | | `store_location` | string\|null | no | Opt-in only. | | `purchase_date` | string\|null | no | Opt-in only. | @@ -41,7 +41,7 @@ This is what the aggregator groups on for per-SKU baselines. | `macos_version` | string | e.g. `"macOS 16.3 (Tahoe)"` | | `kernel_version` | string | e.g. `"Darwin 26.3.0"` | | `serial_hash` | string | `"sha256:"` — hashed by `inventory.sh`. See "Hash caveat" under Privacy below. | -| `serial_number` | string | **only present** if user set `INCLUDE_PLAINTEXT_SERIAL=1`. When present, agent sets `submission_safe: false`. | +| `serial_number` | string | **only present** if user set `INCLUDE_PLAINTEXT_SERIAL=1`. When present, `./run` sets `submission_safe: false` on the local copy and refuses to write the submission copy. | | `power_adapter` | object\|null | `{name, wattage, connected, charging}` from SPPowerDataType — relevant for sustained-perf headroom. | ## Each phase's shape @@ -134,15 +134,15 @@ Produced by `thermal-load.sh`. Includes ambient temp for cross-machine compariso The `intel_*` fields are populated only on Intel Macs (where the chassis class is `intel-laptop` or `intel-desktop`); on Apple Silicon they're `null`. Conversely `p_cluster_freq_mhz` / `combined_power_w` are populated on Apple Silicon and `null` on Intel. -`raw_log_path` points at a per-user tempfile under `/var/folders/...`. The agent strips it from the canonical submission JSON. +`raw_log_path` points at a per-user tempfile under `/var/folders/...`. `./run` strips it from the canonical submission JSON. ## Privacy / submission-safety Reports default to submission-safe: -- **Serial number is hashed** (SHA-256) by `inventory.sh` and `battery.sh`. The plaintext serial is **not** stored unless `INCLUDE_PLAINTEXT_SERIAL=1` is set; when set, the agent flips `submission_safe: false`. +- **Serial number is hashed** (SHA-256) by `inventory.sh` and `battery.sh`. The plaintext serial is **not** stored unless `INCLUDE_PLAINTEXT_SERIAL=1` is set; when set, `./run` keeps the plaintext in the local copy only and strips it from the submission copy. - **`store_location`, `purchase_date`** are `null` by default. Opt in to enable batch-correlation (e.g. "all units bought at HK Apple Causeway Bay in April 2026 with this defect"). -- **`_raw_*` fields** in the inventory and battery sub-blocks contain the full `system_profiler` / `ioreg` dumps and may include paired Bluetooth device IDs, Wi-Fi SSIDs, USB device serials, etc. The agent strips these from the canonical submission JSON and keeps them only in the local `Reports/-raw/*.json` per-phase artifacts. **`submission_safe: true`** asserts they're stripped. -- **`submission_safe: true`** is the agent's assertion that no PII has snuck in. If the user adds free-form notes that might contain identifying info, the agent sets this to `false` and warns. +- **`_raw_*` fields** in the inventory and battery sub-blocks contain the full `system_profiler` / `ioreg` dumps and may include paired Bluetooth device IDs, Wi-Fi SSIDs, USB device serials, etc. `./run` strips these from the submission copy and keeps them only in the local `Reports/local/*.json`. **`submission_safe: true`** asserts they're stripped. +- **`submission_safe: true`** is the orchestrator's assertion that no PII has snuck in. If the user passes `--notes "…"`, the orchestrator flips this to `false` since notes may contain identifying info. ### Hash caveat — obfuscation, not anonymization diff --git a/SECURITY.md b/SECURITY.md index 2013f2f..4c39224 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,11 +14,11 @@ Shakedown runs entirely on the user's own Mac. The surface area is small: five s If you submit a report to the (planned) hosted aggregator: -- **Serial numbers are SHA-256 hashed by default.** `inventory.sh` and `battery.sh` emit `serial_hash` instead of the raw serial. The plaintext is included only if you set `INCLUDE_PLAINTEXT_SERIAL=1` (in which case the agent flips `submission_safe` to `false`). +- **Serial numbers are SHA-256 hashed by default.** `inventory.sh` and `battery.sh` emit `serial_hash` instead of the raw serial. The plaintext is included only if you set `INCLUDE_PLAINTEXT_SERIAL=1` — even then, `./run` keeps the plaintext in the local copy only and never writes it to the submission copy. - **The hash is obfuscation, not anonymization.** Apple's serial space has limited entropy (~10⁸ combinations per chassis SKU); a determined operator could rainbow-table the original. Use the hash for *deduplication* assumptions, not *untraceability*. A future aggregator deployment should rotate to HMAC-SHA-256 with a deployment-secret salt — see [`Reports/SCHEMA.md`](Reports/SCHEMA.md#hash-caveat--obfuscation-not-anonymization). -- **`_raw_*` fields** in the inventory and battery JSON contain full `system_profiler` / `ioreg` dumps that may include paired Bluetooth device IDs, Wi-Fi SSIDs, USB device serials, and other environment fingerprints. The agent strips these before submission. They remain in the local per-phase artifacts under `Reports/-raw/*.json` for your own debugging. +- **`_raw_*` fields** in the inventory and battery JSON contain full `system_profiler` / `ioreg` dumps that may include paired Bluetooth device IDs, Wi-Fi SSIDs, USB device serials, and other environment fingerprints. `./run` strips these from the submission copy. They remain in `Reports/local/.json` (gitignored) for your own debugging. - **`store_location`, `purchase_date`** are `null` by default and only populated if you opt in. -- **`submission_safe: true`** is the agent's assertion that none of the above leaked through. Always check it before submitting. +- **`submission_safe: true`** is the orchestrator's assertion that none of the above leaked through. Always check it before submitting. ## Reporting a vulnerability diff --git a/Shakedown Brain.md b/Shakedown Brain.md index a331e4f..ca6ecfc 100644 --- a/Shakedown Brain.md +++ b/Shakedown Brain.md @@ -7,15 +7,15 @@ tags: [moc, index] Obsidian map-of-content for the Shakedown repo. If you opened the folder in Obsidian, this is your entry point. If you're browsing on GitHub, [README.md](README.md) is the better entry point. -Shakedown is a Claude-Code-driven verification harness for new Apple Silicon Macs — a procedure for catching batch-level defects in the return window. (Name borrowed from engineering — a *shakedown run* is the first-run stress test of new machinery before commissioning.) +Shakedown is a verification harness for new Macs — a procedure for catching batch-level defects in the return window. (Name borrowed from engineering — a *shakedown run* is the first-run stress test of new machinery before commissioning.) ## Map of Content ### Verification machinery (generation-agnostic) - [[Verification/Runbook]] — phase-by-phase procedure (Pre-flight → Inventory → Battery → Variance → Thermal → Display → Manual → Diagnostics → Drain → Report) - [[Verification/Pass-Fail Criteria]] — concrete thresholds, parameterized by chassis class -- [[AGENTS]] — agent operating manual (cross-tool convention; Claude Code reads it via the [[CLAUDE]] pointer) - Scripts in `Verification/scripts/`: + - `run-shakedown.sh` — orchestrator (`./run` at repo root execs this) - `inventory.sh` — `system_profiler` + `sysctl` → JSON - `battery.sh` — `ioreg` battery health → JSON - `cpu-variance.sh` — burst + chassis-class-aware warmup + 5×60s timed iters, throughput stats → JSON @@ -50,4 +50,4 @@ Shakedown is a Claude-Code-driven verification harness for new Apple Silicon Mac The defect class that motivated Shakedown: **batch-level performance variance** that doesn't show up in a quick boot test. The 2026 M5 Max line had units showing up to 41.5% multi-core variance between identical runs — invisible until you ran repeated sustained-load benchmarks on a thermally-saturated chassis. This is what `cpu-variance.sh` is designed to catch. -Cosmetic / build issues (hinge creak, dead pixels, speaker crackle) are obvious on first inspection — the agent prompts you for those manually. The unique value of the harness is in the load-and-thermal phases. +Cosmetic / build issues (hinge creak, dead pixels, speaker crackle) are obvious on first inspection — those are phases 6–9, walked through manually following the runbook. The unique value of the harness is in the automated load-and-thermal phases. diff --git a/Verification/Runbook.md b/Verification/Runbook.md index 86542f5..3a5319f 100644 --- a/Verification/Runbook.md +++ b/Verification/Runbook.md @@ -5,11 +5,11 @@ tags: [verification, runbook] # QA Runbook -The procedure followed by the agent (or by a human running manually). Each phase has a goal, an action, and a pass condition. See [Pass-Fail Criteria](Pass-Fail%20Criteria.md) for the consolidated thresholds and [the JSON report schema](../Reports/SCHEMA.md) for the canonical output format. +The procedure for each verification phase. Phases 0–5 are automated by [`./run`](../README.md#quick-start); phases 6–9 are manual checks you step through yourself. Each phase has a goal, an action, and a pass condition. See [Pass-Fail Criteria](Pass-Fail%20Criteria.md) for the consolidated thresholds and [the JSON report schema](../Reports/SCHEMA.md) for the canonical output format. **Total time:** ~45 min on a MacBook Pro, ~25 min on a fanless Air, ~35 min on a desktop or Intel Mac, +30 min if you opt into the idle-drain test. -> **Reads target from:** the user's invocation. If they passed `--target ` or "against target X", the agent loads `targets/.json` and uses those assertions for Phase 1. Otherwise the agent asks for chip / RAM / chassis at start, or runs without assertions if the user opts out. +> **Target preset.** Pass `--target ` to assert the unit matches the preset in `targets/.json`. Without `--target`, the inventory asserts are skipped — useful for Macs that don't yet have a preset, or for self-testing an existing unit. ## Phase 0 — Pre-flight (1 min) @@ -180,11 +180,11 @@ Frequency cliffs to base clock within 30 s under any chassis are the bad-batch / ./Verification/scripts/display-test.sh ``` -This opens an HTML page in the default browser. The agent prompts the user: +This opens an HTML page in the default browser. > Press **F** to fullscreen, **Space** to start cycling. ← / → to navigate manually. **Esc** exits fullscreen, **Cmd-W** closes the tab when done. -Walk through the colors with the user, asking for each: +Walk through each color, asking yourself: | Color | Question to user | |---|---| @@ -199,7 +199,7 @@ Record yes/no per color. Severe issues → fail. Minor mini-LED blooming around ## Phase 7 — Manual physical inspection (~5 min) -**Goal:** find build quality issues invisible to software. The agent asks the user one question at a time and records the answer. +**Goal:** find build quality issues invisible to software. Step through each check: 1. **Hinge creak.** Open and close the lid 3–4 times slowly. Open to ~110°. Press firmly on each palmrest corner. Twist the chassis gently. *Did you hear creaking?* @@ -223,28 +223,15 @@ Record yes/no per color. Severe issues → fail. Minor mini-LED blooming around 11. **Bluetooth.** Pair any device (AirPods, phone). *Connects?* -If any answer is "yes, there's an issue" the agent records it as a fail with severity per [Pass-Fail Criteria](Pass-Fail%20Criteria.md). +If any answer is "yes, there's an issue", record it as a fail with severity per [Pass-Fail Criteria](Pass-Fail%20Criteria.md). ## Phase 8 — Apple Diagnostics (~5–10 min, includes reboot) **Goal:** Apple's own hardware self-test. -> Before this phase, **write a partial JSON+MD report** to `Reports/.{json,md}` with progress so far, in case the session ends or the user closes the terminal during the reboot. +Save your work. Reboot. As soon as the Mac powers off, **press and hold the power button** until "Loading startup options" appears. Release. Press **Cmd-D** (or follow the on-screen prompt for Diagnostics). Run the test. -Prompt the user: - -> Save your work. Reboot. As soon as the Mac powers off, **press and hold the power button** until "Loading startup options" appears. Release. Press **Cmd-D** (or follow the on-screen prompt for Diagnostics). Run the test. - -When done, the user comes back to terminal and resumes the agent: - -```bash -cd ~/mac-shakedown -# Claude Code: -claude continue # or just `claude` and continue the conversation -# Other agents: re-invoke per your tool's resume convention -``` - -The user reports the result code(s) or "no issues found." Pass = all clear. Any reference code = fail; cross-reference Apple's [diagnostic codes](https://support.apple.com/en-us/102713) in the report. +Record the result code(s) or "no issues found." Pass = all clear. Any reference code = fail; cross-reference Apple's [diagnostic codes](https://support.apple.com/en-us/102713) in the report. ## Phase 9 — Optional idle drain test (~30 min) diff --git a/Verification/scripts/cpu-variance.sh b/Verification/scripts/cpu-variance.sh index 8a0b75a..18a7d91 100755 --- a/Verification/scripts/cpu-variance.sh +++ b/Verification/scripts/cpu-variance.sh @@ -173,7 +173,7 @@ if len(throughputs) >= 4: decline_pct = round((early - late) / early * 100, 3) if early else None # Burst-to-steady ratio: ADVISORY. Without crowd-sourced calibration baseline -# this is hard to interpret — record it for the agent / aggregator to judge. +# this is hard to interpret — record it for the aggregator to judge. burst_to_steady_ratio = None if burst_throughput and burst_throughput > 0: burst_to_steady_ratio = round(mean / burst_throughput, 4) @@ -222,7 +222,7 @@ if decline_pct is not None: elif decline_pct > 5: warn_signals.append(f"throughput declined {decline_pct:.1f}% in 5–10% warn range") -# Burst-to-steady ratio — recorded as info, agent / aggregator interprets. +# Burst-to-steady ratio — recorded as info, aggregator interprets. if burst_to_steady_ratio is not None and burst_to_steady_ratio > 0.97: info_signals.append( f"burst-to-steady {burst_to_steady_ratio:.2f}× — chassis gives back almost no thermal " diff --git a/examples/m5-2026/README.md b/examples/m5-2026/README.md index 1ba5c86..9bb9c7e 100644 --- a/examples/m5-2026/README.md +++ b/examples/m5-2026/README.md @@ -2,7 +2,7 @@ Worked example covering the 2026 Apple Silicon M5 generation — `Apple M5`, `M5 Pro`, and `M5 Max` across MacBook Air, MacBook Pro 14"/16", Mac mini, Mac Studio, and iMac. Most of the documented batch defects so far cluster around the **M5 Max** specifically (notably the multi-core performance variance described in [Issues/Performance Variance.md](Issues/Performance%20Variance.md)), but the surrounding context — display / battery / build quality / repairability — applies to the whole generation. -This folder is **input to the agent** — it reads `M5 Quality Issues.md` and the per-issue notes in `Issues/` to interpret findings against documented batch defects, and to cite specific issues in failure reports. +This folder is **the reference material for failure analysis** — when `./run` produces a WARN or FAIL verdict on a Mac whose target preset points here, read `M5 Quality Issues.md` and the relevant per-issue note to interpret the finding against documented batch defects. ## What's documented here @@ -26,14 +26,11 @@ cp -r examples/m5-2026 examples/m6-2027 # update the notes inside, then add a target preset that points at the new dir ``` -Calibrations are deliberately Markdown — meant to be read, edited, and discussed by humans, not parsed by code. The agent reads them via normal file-reading and applies judgment. +Calibrations are deliberately Markdown — meant to be read, edited, and discussed by humans, not parsed by code. -## How the agent uses this folder +## How to use this folder -When a target preset has `"calibration_dir": "examples/m5-2026"`, the agent: -1. Reads `M5 Quality Issues.md` at session start to understand the defect landscape for this generation. -2. Cross-references findings against `Issues/.md` when a phase produces WARN or FAIL. -3. Cites the relevant note in the final report so the user has supporting evidence. +When a target preset has `"calibration_dir": "examples/m5-2026"`, this folder is the documented defect landscape for that generation. After `./run` finishes, if any phase produced WARN or FAIL, open `M5 Quality Issues.md` and cross-reference against the relevant per-issue note in `Issues/` to decide whether the signal matches a known defect class. ## Variant scope diff --git a/examples/sample-report-illustrative/2026-04-30T14-30-00-mbp-16-m5-max-64.md b/examples/sample-report-illustrative/2026-04-30T14-30-00-mbp-16-m5-max-64.md index c7fc8e5..029748e 100644 --- a/examples/sample-report-illustrative/2026-04-30T14-30-00-mbp-16-m5-max-64.md +++ b/examples/sample-report-illustrative/2026-04-30T14-30-00-mbp-16-m5-max-64.md @@ -77,4 +77,4 @@ All clean. Hinge solid, every key responsive, all four trackpad corners click un --- -*Report generated by [Shakedown](https://github.com/ugglr/mac-shakedown) v0.1.0 driven by Claude Code. JSON schema v1.0 — see [`Reports/SCHEMA.md`](../../Reports/SCHEMA.md). This is an **illustrative example**, not a real run.* +*Report generated by [Shakedown](https://github.com/ugglr/mac-shakedown) v0.1.0. JSON schema v1.0 — see [`Reports/SCHEMA.md`](../../Reports/SCHEMA.md). This is an **illustrative example**, not a real run.* diff --git a/targets/README.md b/targets/README.md index dbe0349..904ec01 100644 --- a/targets/README.md +++ b/targets/README.md @@ -1,15 +1,15 @@ # Target presets -Each `*.json` in this folder is a preset spec for a specific Mac SKU. When the agent runs the QA, you can specify a target preset to skip the manual config dialogue: +Each `*.json` in this folder is a preset spec for a specific Mac SKU. Pass it to `./run` to assert the unit matches: ```bash -claude "run QA against target mbp-16-m5-max-64" +./run --target mbp-16-m5-max-64 ``` -The agent loads the JSON, asserts against the unit, and references the preset's `calibration_dir` for known issues. +`./run` loads the JSON, asserts chip / memory / model substrings against the unit, and references the preset's `calibration_dir` for known issues. If your config isn't here, either: -- Run without a target and the agent will ask you for chip / RAM / chassis interactively, or +- Run without `--target` — chassis class auto-detects, the SKU asserts are skipped, but the variance / thermal / battery checks still run, or - Add a new preset (see [CONTRIBUTING.md](../CONTRIBUTING.md#adding-a-target-preset)) ## Schema diff --git a/targets/mbp-16-intel-2019.json b/targets/mbp-16-intel-2019.json index ec0bb4d..caacae9 100644 --- a/targets/mbp-16-intel-2019.json +++ b/targets/mbp-16-intel-2019.json @@ -5,5 +5,5 @@ "model_must_include": "16", "calibration_dir": "examples/m5-2026", "thermal_chassis_class": "intel-laptop", - "_note": "Memory varies (16/32/64 GB). Set the agent's verbal target to your actual config (e.g. 'I bought a 16-inch Intel MBP with 32 GB') so the assertion is meaningful. The M5 calibration is reused as a placeholder until an Intel-era calibration is added — different defect classes apply (T2 issues, butterfly-keyboard 2018-19, GPU stutter 2019)." + "_note": "Memory varies (16/32/64 GB). The preset's memory_gb is null so the memory assert is skipped — clone this file and pin memory_gb to your specific config (16, 32, or 64) for a hard assert. The M5 calibration is reused as a placeholder until an Intel-era calibration is added — different defect classes apply (T2 issues, butterfly-keyboard 2018-19, GPU stutter 2019)." }