Skip to content

Commit c53c46b

Browse files
hyperpolymathclaude
andcommitted
feat(mass-panic): Chapel HPC mass-panic module + fNIRS paper draft + results
- Chapel MassPanic/Protocol/Temporal upgrades for high-throughput panic analysis - Rust main.rs integration updates - New fNIRS paper draft (460 lines, complete with references) - 8 benchmark result JSON files from assemblyline + system-image runs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e0e2396 commit c53c46b

14 files changed

Lines changed: 3510 additions & 38 deletions

ROADMAP.adoc

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ binary, panicbot (gitbot-fleet CI integration), and mass-panic (org-scale batch
4343
* [x] Filesystem persistence for scan results
4444
* [x] VeriSimDB HTTP API integration: push octads via REST (ureq v3; VERISIMDB_URL env var; http feature; filesystem fallback)
4545
* [x] Per-project VeriSimDB instance: `deploy/panic-attack/fly.toml` for `verisim-panic-api` (6PN internal, lhr)
46-
* [ ] Delta reporting: only report changes since last scan
46+
* [x] Delta reporting: `diff` subcommand reports changes since last scan (`src/report/diff.rs`)
4747
* [ ] Hexad persistence for Patch Bridge mitigation registry (currently JSON file)
4848
* [ ] Historical trend queries via VCL
4949

@@ -53,8 +53,8 @@ binary, panicbot (gitbot-fleet CI integration), and mass-panic (org-scale batch
5353
* [x] JSON output for pipeline integration
5454
* [x] A2ML manifest protocol support
5555
* [x] i18n support (10 languages, ISO 639-1)
56-
* [ ] Shell completions: bash, zsh, fish, nushell
57-
* [ ] Interactive TUI mode for reviewing findings
56+
* [x] Shell completions: bash, zsh, fish, nushell, powershell (`completions/` directory)
57+
* [x] Interactive TUI mode for reviewing findings (`tui` subcommand)
5858
* [ ] Improved error messages with fix suggestions
5959

6060
== v2.4.0 -- Patch Bridge Phase 2
@@ -134,9 +134,13 @@ Identified as an estate-wide gap in the 2026-04-05 KRL-stack CRG blitz audit.
134134
* [x] Assemblyline batch scanning with rayon parallelism
135135
* [x] BLAKE3 fingerprinting for incremental scanning
136136
* [x] Notification pipeline (markdown summaries, GitHub issues)
137-
* [ ] Chapel metalayer: distributed `coforall` scanning across compute clusters
138-
* [ ] Multi-machine orchestration for org-scale scanning
139-
* [ ] Scan result aggregation across distributed nodes
137+
* [x] Chapel metalayer: `mass-panic` orchestrator with fNIRS-inspired SystemImage, temporal snapshots, VeriSimDB hexad persistence (`chapel/src/`)
138+
* [x] `fingerprint` subcommand: BLAKE3 directory hashing for incremental skip
139+
* [x] Temporal diff subcommand: `--subcommand=diff` with global health/risk/weak-point deltas
140+
* [x] Single-locale scan validated against 303-repo estate (2026-04-12)
141+
* [ ] Per-node temporal diff: load full SystemImage JSON for per-repo health breakdown
142+
* [ ] Multi-machine orchestration: gasnet/ofi multi-locale Chapel run across cluster nodes
143+
* [ ] VeriSimDB HTTP push from Chapel metalayer (currently file-only)
140144

141145
== v3.1.0 -- Ecosystem Integration
142146

chapel/src/MassPanic.chpl

Lines changed: 80 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -146,16 +146,14 @@ module MassPanic {
146146
var results = allResults[0..#actualCount];
147147
sort(results, comparator=new ResultComparator());
148148

149-
// Filter to only repos with findings if --findingsOnly
150-
var filteredResults = results;
151-
if findingsOnly {
152-
var kept: list(RepoResult);
153-
for r in results {
154-
if r.weakPointCount > 0 || r.error != "" then
155-
kept.pushBack(r);
156-
}
157-
filteredResults = kept.toArray();
149+
// Filter to only repos with findings if --findingsOnly.
150+
// Always build via list to avoid Chapel array-shape mismatch on assignment.
151+
var filteredList: list(RepoResult);
152+
for r in results {
153+
if !findingsOnly || r.weakPointCount > 0 || r.error != "" then
154+
filteredList.pushBack(r);
158155
}
156+
var filteredResults = filteredList.toArray();
159157

160158
// Build system image
161159
var image = buildSystemImage(filteredResults, repos.size);
@@ -242,9 +240,8 @@ module MassPanic {
242240
return;
243241
}
244242

245-
// Build minimal SystemImage objects from snapshot summaries —
246-
// global metrics are accurate; per-node breakdown is not available
247-
// without loading the full image JSON.
243+
// Build SystemImage objects. Start with summary metrics (always available),
244+
// then load per-node data from saved image files when paths are present.
248245
var olderImg: SystemImage;
249246
olderImg.generatedAt = fromSnap.timestamp;
250247
olderImg.globalHealth = fromSnap.globalHealth;
@@ -263,6 +260,28 @@ module MassPanic {
263260
newerImg.reposScanned = toSnap.reposScanned;
264261
newerImg.nodeCount = toSnap.nodeCount;
265262

263+
// Load per-node data if image files are available (written by takeSnapshot).
264+
// Older snapshots written before this feature won't have imagePath set — the
265+
// diff will still work with summary-only data, just no per-node breakdown.
266+
if fromSnap.imagePath != "" {
267+
const olderNodes = loadImageNodes(fromSnap.imagePath);
268+
if olderNodes.size > 0 {
269+
olderImg.nodes = olderNodes;
270+
if !quiet then
271+
writeln("mass-panic diff: loaded ", olderNodes.size,
272+
" nodes from ", fromSnap.id);
273+
}
274+
}
275+
if toSnap.imagePath != "" {
276+
const newerNodes = loadImageNodes(toSnap.imagePath);
277+
if newerNodes.size > 0 {
278+
newerImg.nodes = newerNodes;
279+
if !quiet then
280+
writeln("mass-panic diff: loaded ", newerNodes.size,
281+
" nodes from ", toSnap.id);
282+
}
283+
}
284+
266285
const diff = diffSnapshots(olderImg, newerImg, fromSnap.tag, toSnap.tag);
267286

268287
// Output
@@ -301,10 +320,28 @@ module MassPanic {
301320
writeln("New repos: +", diff.newNodes.size);
302321
if diff.removedNodes.size > 0 then
303322
writeln("Gone repos: -", diff.removedNodes.size);
304-
if diff.improvedNodes.size > 0 then
323+
324+
// Per-node breakdown (only populated when full image files are available)
325+
if diff.improvedNodes.size > 0 {
305326
writeln("Improved: ", diff.improvedNodes.size, " repos");
306-
if diff.degradedNodes.size > 0 then
327+
for delta in diff.improvedNodes {
328+
writeln("", delta.name,
329+
" health ", formatDelta(delta.healthAfter - delta.healthBefore),
330+
" wp ", formatDeltaInt(delta.weakPointsAfter - delta.weakPointsBefore));
331+
}
332+
}
333+
if diff.degradedNodes.size > 0 {
307334
writeln("Degraded: ", diff.degradedNodes.size, " repos");
335+
for delta in diff.degradedNodes {
336+
writeln("", delta.name,
337+
" health ", formatDelta(delta.healthAfter - delta.healthBefore),
338+
" wp ", formatDeltaInt(delta.weakPointsAfter - delta.weakPointsBefore));
339+
}
340+
}
341+
if diff.improvedNodes.size == 0 && diff.degradedNodes.size == 0 &&
342+
diff.unchangedCount == 0 {
343+
writeln("(run two scans to enable per-repo breakdown)");
344+
}
308345
writeln();
309346
}
310347

@@ -323,7 +360,31 @@ module MassPanic {
323360
writer.writeln(" \"removed_repos\": ", diff.removedNodes.size, ",");
324361
writer.writeln(" \"improved_repos\": ", diff.improvedNodes.size, ",");
325362
writer.writeln(" \"degraded_repos\": ", diff.degradedNodes.size, ",");
326-
writer.writeln(" \"unchanged_repos\": ", diff.unchangedCount);
363+
writer.writeln(" \"unchanged_repos\": ", diff.unchangedCount, ",");
364+
365+
// Per-node deltas — empty arrays when image files were not available
366+
writer.writeln(" \"improved\": [");
367+
for (delta, idx) in zip(diff.improvedNodes, 0..) {
368+
if idx > 0 then writer.write(", ");
369+
writer.write("{\"id\": \"", delta.nodeId, "\", \"name\": \"", delta.name, "\", ");
370+
writer.write("\"health_before\": ", delta.healthBefore, ", ");
371+
writer.write("\"health_after\": ", delta.healthAfter, ", ");
372+
writer.write("\"wp_before\": ", delta.weakPointsBefore, ", ");
373+
writer.write("\"wp_after\": ", delta.weakPointsAfter, "}");
374+
}
375+
writer.writeln("\n ],");
376+
377+
writer.writeln(" \"degraded\": [");
378+
for (delta, idx) in zip(diff.degradedNodes, 0..) {
379+
if idx > 0 then writer.write(", ");
380+
writer.write("{\"id\": \"", delta.nodeId, "\", \"name\": \"", delta.name, "\", ");
381+
writer.write("\"health_before\": ", delta.healthBefore, ", ");
382+
writer.write("\"health_after\": ", delta.healthAfter, ", ");
383+
writer.write("\"wp_before\": ", delta.weakPointsBefore, ", ");
384+
writer.write("\"wp_after\": ", delta.weakPointsAfter, "}");
385+
}
386+
writer.writeln("\n ]");
387+
327388
writer.writeln("}");
328389
}
329390

@@ -489,12 +550,14 @@ module MassPanic {
489550

490551
select mode {
491552
when "assail" {
553+
// --quiet causes assail to emit JSON on stdout (no --output needed)
554+
args.pushBack("--quiet");
492555
args.pushBack("assail");
493556
args.pushBack(repoPath);
494-
args.pushBack("--output-format=json");
495557
}
496558
when "assault" {
497559
// Full stress test: assail + attack all axes
560+
args.pushBack("--quiet");
498561
args.pushBack("assault");
499562
args.pushBack(repoPath);
500563
args.pushBack("--output-format=json");
@@ -507,6 +570,7 @@ module MassPanic {
507570
}
508571
when "ambush" {
509572
// Timeline-driven stress test
573+
args.pushBack("--quiet");
510574
args.pushBack("ambush");
511575
args.pushBack(repoPath);
512576
args.pushBack("--output-format=json");

chapel/src/Protocol.chpl

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -97,18 +97,38 @@ module Protocol {
9797
result.repoPath = repoPath;
9898
result.repoName = basename(repoPath);
9999

100-
// Extract key fields from JSON using simple string matching.
101-
// This avoids a full JSON parser dependency — panic-attack's output
102-
// format is stable and well-defined by the panicbot JSON contract.
103-
result.weakPointCount = extractInt(jsonStr, "\"weak_points\":");
100+
// The assail JSON format is:
101+
// "weak_points": [ {"category": "...", "severity": "High", ...}, ... ]
102+
// "statistics": { "total_lines": N, "unsafe_blocks": N, ... }
103+
//
104+
// Count weak points by counting "category": occurrences (one per weak point).
105+
// Count severity grades by scanning for "severity": "Critical" etc. (note space after colon).
106+
// total_lines lives under statistics but extractInt finds the first occurrence anywhere.
107+
// total_files is not in the output — approximated from file_statistics array length.
108+
result.weakPointCount = countOccurrences(jsonStr, "\"category\":");
104109
result.criticalCount = countSeverity(jsonStr, "Critical");
105110
result.highCount = countSeverity(jsonStr, "High");
106-
result.totalFiles = extractInt(jsonStr, "\"total_files\":");
111+
result.totalFiles = countOccurrences(jsonStr, "\"file_path\":");
107112
result.totalLines = extractInt(jsonStr, "\"total_lines\":");
108113

109114
return result;
110115
}
111116

117+
// Count non-overlapping occurrences of a search string in json.
118+
proc countOccurrences(json: string, searchStr: string): int {
119+
var count = 0;
120+
var remaining = json;
121+
while remaining.size > 0 {
122+
const idx = remaining.find(searchStr);
123+
if idx == -1 then break;
124+
count += 1;
125+
var rest: string;
126+
try { rest = remaining[idx..]; } catch { break; }
127+
try { remaining = rest[searchStr.size..]; } catch { break; }
128+
}
129+
return count;
130+
}
131+
112132
proc extractInt(json: string, key: string): int {
113133
const idx = json.find(key);
114134
if idx == -1 then return 0;
@@ -131,16 +151,22 @@ module Protocol {
131151
return 0;
132152
}
133153

154+
// Count severity occurrences. The assail JSON emits `"severity": "High"` with
155+
// a space after the colon, so we match both spaced and compact forms.
134156
proc countSeverity(json: string, severity: string): int {
135-
// Slice-and-search: advance through the string by taking suffixes.
136-
const searchStr = "\"severity\":\"" + severity + "\"";
137157
var count = 0;
138-
var remaining = json;
139-
while remaining.size > 0 {
140-
const idx = remaining.find(searchStr);
141-
if idx == -1 then break;
142-
count += 1;
143-
remaining = try! (try! remaining[idx..])[searchStr.size..];
158+
// Try both "severity": "X" (spaced) and "severity":"X" (compact)
159+
for searchStr in ["\"severity\": \"" + severity + "\"",
160+
"\"severity\":\"" + severity + "\""] {
161+
var remaining = json;
162+
while remaining.size > 0 {
163+
const idx = remaining.find(searchStr);
164+
if idx == -1 then break;
165+
count += 1;
166+
var rest: string;
167+
try { rest = remaining[idx..]; } catch { break; }
168+
try { remaining = rest[searchStr.size..]; } catch { break; }
169+
}
144170
}
145171
return count;
146172
}

chapel/src/Temporal.chpl

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ module Temporal {
275275
snap.totalCritical = extractInt(obj, "\"critical\":");
276276
snap.reposScanned = extractInt(obj, "\"repos\":");
277277
snap.nodeCount = extractInt(obj, "\"nodes\":");
278+
snap.imagePath = extractQuotedString(obj, "\"image_path\":");
278279

279280
if snap.id != "" then results.pushBack(snap);
280281
}
@@ -283,6 +284,69 @@ module Temporal {
283284
return results;
284285
}
285286

287+
// ---------------------------------------------------------------------------
288+
// Full image node loading — enables per-node diff
289+
// ---------------------------------------------------------------------------
290+
291+
// Load the node list from a saved SystemImage JSON file.
292+
// The image JSON contains a "nodes" array where each entry is a single-line
293+
// JSON object as written by writeNodeJson in Imaging.chpl.
294+
// Returns an empty list if the file is missing, unreadable, or has no nodes.
295+
proc loadImageNodes(imagePath: string): list(ImageNode) {
296+
var nodes: list(ImageNode);
297+
if imagePath == "" || !safeIsFile(imagePath) then return nodes;
298+
299+
var inNodes = false;
300+
try {
301+
var f = open(imagePath, ioMode.r);
302+
var reader = f.reader(locking=false);
303+
var line: string;
304+
while reader.readLine(line, stripNewline=true) {
305+
const trimmed = line.strip();
306+
307+
if trimmed.startsWith("\"nodes\"") {
308+
inNodes = true;
309+
continue;
310+
}
311+
// The edges array follows nodes — stop when we hit it
312+
if inNodes && trimmed.startsWith("\"edges\"") {
313+
break;
314+
}
315+
if inNodes && trimmed.startsWith("]") {
316+
inNodes = false;
317+
continue;
318+
}
319+
320+
if !inNodes then continue;
321+
if trimmed == "" || trimmed == "[" then continue;
322+
323+
// Strip trailing comma if present
324+
var obj = if trimmed.endsWith(",") then trimmed[..trimmed.size - 2] else trimmed;
325+
if !obj.startsWith("{") then continue;
326+
327+
var node: ImageNode;
328+
node.id = extractQuotedString(obj, "\"id\":");
329+
node.name = extractQuotedString(obj, "\"name\":");
330+
node.level = extractQuotedString(obj, "\"level\":");
331+
node.healthScore = extractReal(obj, "\"health_score\":");
332+
node.riskIntensity = extractReal(obj, "\"risk_intensity\":");
333+
node.weakPointDensity = extractReal(obj, "\"weak_point_density\":");
334+
node.weakPointCount = extractInt(obj, "\"weak_point_count\":");
335+
node.criticalCount = extractInt(obj, "\"critical_count\":");
336+
node.totalFiles = extractInt(obj, "\"total_files\":");
337+
node.totalLines = extractInt(obj, "\"total_lines\":");
338+
node.fingerprint = extractQuotedString(obj, "\"fingerprint\":");
339+
// skipped is an unquoted boolean: scan for "skipped": true
340+
node.skipped = obj.find("\"skipped\": true") != -1 ||
341+
obj.find("\"skipped\":true") != -1;
342+
343+
if node.id != "" then nodes.pushBack(node);
344+
}
345+
} catch { }
346+
347+
return nodes;
348+
}
349+
286350
// Extract a real (floating-point) value following a JSON key.
287351
proc extractReal(json: string, key: string): real {
288352
const idx = json.find(key);
@@ -376,7 +440,7 @@ module Temporal {
376440
w.writeln(existingEntries, ",");
377441
}
378442

379-
// New snapshot entry
443+
// New snapshot entry — image_path enables per-node diff on reload
380444
w.write(" {");
381445
w.write("\"id\": \"", newSnap.id, "\", ");
382446
w.write("\"timestamp\": \"", newSnap.timestamp, "\", ");
@@ -387,7 +451,8 @@ module Temporal {
387451
w.write("\"weak_points\": ", newSnap.totalWeakPoints, ", ");
388452
w.write("\"critical\": ", newSnap.totalCritical, ", ");
389453
w.write("\"repos\": ", newSnap.reposScanned, ", ");
390-
w.write("\"nodes\": ", newSnap.nodeCount);
454+
w.write("\"nodes\": ", newSnap.nodeCount, ", ");
455+
w.write("\"image_path\": \"", newSnap.imagePath, "\"");
391456
w.writeln("}");
392457

393458
w.writeln(" ]");

0 commit comments

Comments
 (0)