@gigzen/populace 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ai.mjs ADDED
@@ -0,0 +1,158 @@
1
+ // The model layer: explanations for failures no rule could name.
2
+ //
3
+ // Deliberately the SECOND thing tried, never the first. src/explain.mjs handles
4
+ // the recognisable shapes — permissions, foreign keys, quotas, sockets — with
5
+ // rules that are instant, free, offline and auditable. This is for the
6
+ // remainder, and it only ever sees the remainder.
7
+ //
8
+ // Raw fetch rather than @anthropic-ai/sdk, and that is a deliberate trade. The
9
+ // SDK is the better tool in almost any other project; here it would be
10
+ // Populace's first runtime dependency, and "zero dependencies" is not a
11
+ // slogan — it is why `npx @gigzen/populace` works with nothing to install.
12
+ // One POST is a fair price for keeping that true.
13
+ //
14
+ // WHAT IS SENT, exactly:
15
+ // · the method name e.g. "sendMessage"
16
+ // · the error message e.g. "TypeError: fetch failed"
17
+ // · how many times e.g. 17
18
+ // · p50/p95 for that method
19
+ // Nothing else. No target URL, no keys, no tokens, no request bodies, no
20
+ // simulated users' content. Everything sent is printed first when --verbose is
21
+ // on, because a tool that quietly ships your data somewhere is not one you
22
+ // should trust with your staging credentials.
23
+
24
+ const ENDPOINT = "https://api.anthropic.com/v1/messages";
25
+ const MODEL = "claude-opus-5";
26
+ const VERSION = "2023-06-01";
27
+
28
+ /** The key, from the environment or the config. Never written to disk by us. */
29
+ export function apiKey(config) {
30
+ return process.env.ANTHROPIC_API_KEY || config?.ai?.apiKey || null;
31
+ }
32
+
33
+ export const isConfigured = (config) => Boolean(apiKey(config));
34
+
35
+ const SYSTEM = `You explain failures from a load-testing tool called Populace, which drives an
36
+ application through its own API as a population of simulated users.
37
+
38
+ You are the fallback. A rule engine already handled every failure it recognised —
39
+ row-level security, foreign keys, expired sessions, provider rate limits, socket
40
+ errors. What reaches you is what it could not name.
41
+
42
+ For each failure, answer three things:
43
+ headline one sentence: what happened. No preamble.
44
+ why two or three sentences: the mechanism. What would produce this?
45
+ fix one or two sentences: what to change. Concrete.
46
+ blame exactly one of: app | environment | harness | unknown
47
+ app the application under test is at fault
48
+ environment the platform or provider refused it (quotas, limits)
49
+ harness it never reached the server (sockets, DNS, the client)
50
+ unknown you genuinely cannot tell
51
+
52
+ Rules you must follow:
53
+ - If you cannot tell, say so and use blame "unknown". A confident wrong cause
54
+ costs more to debug than an honest blank.
55
+ - Never invent a status code, table name, policy name or line number that was
56
+ not in the input.
57
+ - Prefer the boring explanation. Under concurrency, most failures are
58
+ contention, permissions, or a resource limit — not exotic.
59
+
60
+ Reply with JSON only, no prose around it:
61
+ {"explanations":[{"method":"...","headline":"...","why":"...","fix":"...","blame":"..."}]}`;
62
+
63
+ /**
64
+ * Ask the model about failures the rules could not name.
65
+ *
66
+ * Returns [] on any problem — a missing key, a refusal, a network error, a
67
+ * malformed reply. This is an enhancement to a report that is already complete
68
+ * and useful; it must never be the reason a run fails or a report is not
69
+ * written.
70
+ */
71
+ export async function explainWithAI(unexplained, { config, verbose = false, timeoutMs = 30000 } = {}) {
72
+ const key = apiKey(config);
73
+ if (!key || !unexplained?.length) return [];
74
+
75
+ const payload = unexplained.map((e) => ({
76
+ method: e.method,
77
+ error: String(e.message ?? "").slice(0, 500),
78
+ occurrences: e.count ?? 1,
79
+ p50ms: e.p50 ?? null,
80
+ p95ms: e.p95 ?? null,
81
+ }));
82
+
83
+ if (verbose) {
84
+ console.log("\n Sending to the model — this is everything, nothing else leaves this machine:");
85
+ console.log(JSON.stringify(payload, null, 2).split("\n").map((l) => ` ${l}`).join("\n"));
86
+ }
87
+
88
+ const controller = new AbortController();
89
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
90
+
91
+ try {
92
+ const res = await fetch(ENDPOINT, {
93
+ method: "POST",
94
+ signal: controller.signal,
95
+ headers: {
96
+ "content-type": "application/json",
97
+ "x-api-key": key,
98
+ "anthropic-version": VERSION,
99
+ },
100
+ body: JSON.stringify({
101
+ model: MODEL,
102
+ max_tokens: 2000,
103
+ // Effort low: this is a small, bounded task on a handful of short
104
+ // strings, and a CLI that pauses for twenty seconds to explain an
105
+ // error is a CLI people turn off.
106
+ output_config: { effort: "low" },
107
+ system: SYSTEM,
108
+ messages: [{
109
+ role: "user",
110
+ content:
111
+ `Failures a rule engine could not classify:\n\n${JSON.stringify(payload, null, 2)}\n\n` +
112
+ `Explain each one.`,
113
+ }],
114
+ }),
115
+ });
116
+
117
+ if (!res.ok) {
118
+ const body = await res.text().catch(() => "");
119
+ return fail(res.status === 401
120
+ ? "the API key was rejected"
121
+ : res.status === 429
122
+ ? "rate limited by the API"
123
+ : `the API returned ${res.status}${body ? `: ${body.slice(0, 160)}` : ""}`);
124
+ }
125
+
126
+ const data = await res.json();
127
+
128
+ // A safety refusal is a normal outcome, not a crash.
129
+ if (data.stop_reason === "refusal") return fail("the model declined to answer");
130
+
131
+ const text = (data.content || []).filter((b) => b.type === "text").map((b) => b.text).join("");
132
+ const json = text.match(/\{[\s\S]*\}/);
133
+ if (!json) return fail("the reply was not JSON");
134
+
135
+ const parsed = JSON.parse(json[0]);
136
+ const BLAME = new Set(["app", "environment", "harness", "unknown"]);
137
+
138
+ return (parsed.explanations || []).map((e) => ({
139
+ method: String(e.method ?? "unknown"),
140
+ headline: String(e.headline ?? "").trim(),
141
+ why: String(e.why ?? "").trim(),
142
+ fix: e.fix ? String(e.fix).trim() : undefined,
143
+ // An unrecognised blame value becomes "unknown" rather than being trusted.
144
+ blame: BLAME.has(e.blame) ? e.blame : "unknown",
145
+ rule: "model",
146
+ source: "model",
147
+ })).filter((e) => e.headline);
148
+ } catch (error) {
149
+ return fail(error.name === "AbortError" ? `no reply within ${timeoutMs / 1000}s` : error.message);
150
+ } finally {
151
+ clearTimeout(timer);
152
+ }
153
+
154
+ function fail(why) {
155
+ console.log(` (the model layer was skipped — ${why})`);
156
+ return [];
157
+ }
158
+ }
package/src/cli.mjs CHANGED
@@ -19,6 +19,10 @@ import { renderSmoke, smoke, smokePersona } from "./smoke.mjs";
19
19
  import { PACKAGE_NAME, VERSION } from "./version.mjs";
20
20
  import { World } from "./engine/world.mjs";
21
21
  import { buildPersonas, CITIES } from "./engine/personas.mjs";
22
+ import * as openapi from "./openapi.mjs";
23
+ import { explainReport, verdictLine } from "./explain.mjs";
24
+ import { explainWithAI, isConfigured } from "./ai.mjs";
25
+ import { updateCommand, updateNotice } from "./update.mjs";
22
26
  import { Agent } from "./engine/agent.mjs";
23
27
 
24
28
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -49,6 +53,7 @@ function overridesFromFlags() {
49
53
  if (flag("minutes")) o.minutes = Number(flag("minutes"));
50
54
  if (flag("tick")) o.tickSeconds = Number(flag("tick"));
51
55
  if (flag("cities")) o.cities = String(flag("cities")).split(",").map((s) => s.trim()).filter(Boolean);
56
+ if (flag("engagement")) o.engagement = Number(flag("engagement"));
52
57
  if (flag("report")) o.reportPath = flag("report");
53
58
  return o;
54
59
  }
@@ -90,7 +95,20 @@ async function init() {
90
95
 
91
96
  fs.mkdirSync(adapterDir, { recursive: true });
92
97
  fs.copyFileSync(path.join(here, "..", "populace.config.example.mjs"), configFile);
93
- if (!fs.existsSync(adapterFile) || has("force")) {
98
+
99
+ // --from-openapi fills the template's paths in from an API description. The
100
+ // adapter is still a draft afterwards; the point is to remove the half hour
101
+ // of looking up thirteen endpoints by hand, not to finish the job.
102
+ const specPath = flag("from-openapi", null);
103
+ let matched = null;
104
+ if (specPath && !blank) {
105
+ const doc = openapi.load(specPath);
106
+ const { operationCount, results } = openapi.match(doc);
107
+ const template = fs.readFileSync(path.join(here, "..", "adapters", templateName), "utf8");
108
+ const { source, applied } = openapi.fill(template, results);
109
+ if (!fs.existsSync(adapterFile) || has("force")) fs.writeFileSync(adapterFile, source);
110
+ matched = { operationCount, results, applied };
111
+ } else if (!fs.existsSync(adapterFile) || has("force")) {
94
112
  fs.copyFileSync(path.join(here, "..", "adapters", templateName), adapterFile);
95
113
  }
96
114
 
@@ -100,6 +118,32 @@ async function init() {
100
118
  adapters/my-app.mjs ← ${blank ? "an empty contract to fill in" : "a working REST adapter to edit"}
101
119
  `);
102
120
 
121
+ if (matched) {
122
+ const { operationCount, results } = matched;
123
+ const order = { high: 0, medium: 1, low: 2, none: 3 };
124
+ const rows = Object.entries(results).sort((a, b) => order[a[1].confidence] - order[b[1].confidence]);
125
+ const found = rows.filter(([, r]) => r.op).length;
126
+
127
+ console.log(` Read ${operationCount} operations from ${path.basename(specPath)} and matched ${found} of 13.
128
+ `);
129
+ for (const [method, r] of rows) {
130
+ const mark = { high: "✔", medium: "~", low: "?", none: "✖" }[r.confidence];
131
+ const where = r.op ? `${r.op.verb.toUpperCase()} ${r.op.path}` : "left as the template default";
132
+ console.log(` ${mark} ${method.padEnd(21)} ${where}`);
133
+ if (r.confidence === "low" || r.confidence === "none") {
134
+ console.log(` ${r.why[0]} — check this one`);
135
+ }
136
+ }
137
+ console.log(`
138
+ These are guesses from endpoint names. Request bodies and field names are
139
+ still the template's defaults, so the adapter is a draft, not finished.
140
+
141
+ Next: populace smoke ← exercises each method once and names the first
142
+ one that is wrong
143
+ `);
144
+ return;
145
+ }
146
+
103
147
  if (blank) {
104
148
  console.log(` Every method throws until you write it. Start with createUser and
105
149
  deleteUser — those two are required.
@@ -241,6 +285,12 @@ async function run() {
241
285
  console.log(` Report: ${displayPath(files.json)}`);
242
286
  console.log(` Shareable page: ${displayPath(files.html)}\n`);
243
287
 
288
+ // After the report a person is already reading, never before it, and at most
289
+ // once a day. Silent if the registry cannot be reached — a version check is
290
+ // never worth an error message at the end of a successful run.
291
+ const notice = await updateNotice();
292
+ if (notice) console.log(`${notice}\n`);
293
+
244
294
  if (report.verdict.status !== "clean") process.exitCode = 1;
245
295
  }
246
296
 
@@ -544,7 +594,91 @@ async function version() {
544
594
  console.log(`${PACKAGE_NAME} ${VERSION} · node ${process.version} · ${process.platform}`);
545
595
  }
546
596
 
547
- const commands = { init, doctor, run, clean, demo, report, version, smoke: smokeCmd };
597
+
598
+ // ---------------------------------------------------------------- explain
599
+
600
+ /**
601
+ * Explain a report's failures: rules first, then the model for the remainder.
602
+ *
603
+ * A separate command rather than part of `run` on purpose. A run should not
604
+ * wait on a network call to something else, and it should never fail because
605
+ * an API key was wrong. The report is complete without this.
606
+ */
607
+ async function explainCmd() {
608
+ const reportPath = flag("file", flag("report", "populace-report.json"));
609
+ let report;
610
+ try {
611
+ report = JSON.parse(fs.readFileSync(reportPath, "utf8"));
612
+ } catch (error) {
613
+ console.log(`
614
+ No readable report at ${displayPath(reportPath)} — ${error.message}
615
+ `);
616
+ process.exit(1);
617
+ }
618
+
619
+ const explained = explainReport(report);
620
+ if (!explained.length) {
621
+ console.log(`
622
+ Nothing failed in ${displayPath(reportPath)}. Nothing to explain.
623
+ `);
624
+ return;
625
+ }
626
+
627
+ const BLAME = { app: "YOUR APP", environment: "THE PLATFORM", harness: "THE TEST CLIENT", unknown: "UNKNOWN" };
628
+ const show = (e) => {
629
+ const tag = e.source === "model" ? " · explained by the model" : "";
630
+ console.log(`
631
+ [${BLAME[e.blame]}] ${e.method} × ${e.count ?? 1}${tag}`);
632
+ console.log(` ${e.headline}`);
633
+ console.log(` ${e.why}`);
634
+ if (e.fix) console.log(` Fix: ${e.fix}`);
635
+ };
636
+
637
+ console.log(`
638
+ ${verdictLine(explained)}`);
639
+ const named = explained.filter((e) => e.rule !== "none");
640
+ const unnamed = explained.filter((e) => e.rule === "none");
641
+ for (const e of named) show(e);
642
+
643
+ if (!unnamed.length) {
644
+ console.log(`
645
+ Every failure matched a rule. The model was not needed.
646
+ `);
647
+ return;
648
+ }
649
+
650
+ console.log(`
651
+ ${unnamed.length} failure${unnamed.length === 1 ? "" : "s"} matched no rule.`);
652
+
653
+ const { config } = await open().catch(() => ({ config: null }));
654
+ if (!isConfigured(config)) {
655
+ console.log(`
656
+ Set ANTHROPIC_API_KEY to have the model explain these. It is sent the method
657
+ name, the error text, how many times it happened and that method's latency —
658
+ and nothing else. Run with --verbose to see exactly what leaves the machine.
659
+ `);
660
+ for (const e of unnamed) show(e);
661
+ return;
662
+ }
663
+
664
+ console.log(` Asking the model…`);
665
+ const ai = await explainWithAI(
666
+ unnamed.map((e) => {
667
+ const m = (report.api?.methods || []).find((x) => x.method === e.method);
668
+ return { ...e, p50: m?.latencyMs?.p50, p95: m?.latencyMs?.p95 };
669
+ }),
670
+ { config, verbose: has("verbose") },
671
+ );
672
+
673
+ const byMethod = new Map(ai.map((e) => [e.method, e]));
674
+ for (const e of unnamed) {
675
+ const better = byMethod.get(e.method);
676
+ show(better ? { ...e, ...better } : e);
677
+ }
678
+ console.log("");
679
+ }
680
+
681
+ const commands = { init, doctor, run, clean, demo, report, version, smoke: smokeCmd, explain: explainCmd, update: updateCommand };
548
682
 
549
683
  // `--version` and `-v` are what people actually type.
550
684
  if (has("version") || argv[0] === "-v") {
@@ -563,6 +697,8 @@ if (!commands[command]) {
563
697
  populace run bring the population to life
564
698
  populace clean delete accounts a run created
565
699
  populace report re-open the report from an earlier run
700
+ populace explain say what each failure means and how to fix it
701
+ populace update check whether a newer Populace is out
566
702
  populace version print version and environment
567
703
 
568
704
  Options
@@ -570,6 +706,7 @@ if (!commands[command]) {
570
706
  --agents <n> --minutes <n> override the config
571
707
  --tick <seconds> simulated seconds per step
572
708
  --cities <a,b> ${Object.keys(CITIES).join(", ")}
709
+ --engagement <x> how busy people are; 1 = normal, 5 = relentless
573
710
  --report <path> where to write the report
574
711
  --keep leave accounts in place after a run
575
712
  --file <path> which report to re-open (report)