@intentius/chant 0.65.0 → 0.66.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.
Files changed (58) hide show
  1. package/dist/behaviour-delta.d.ts +181 -0
  2. package/dist/behaviour-delta.d.ts.map +1 -0
  3. package/dist/behaviour-http.d.ts +106 -0
  4. package/dist/behaviour-http.d.ts.map +1 -0
  5. package/dist/behaviour-overlay.d.ts +61 -0
  6. package/dist/behaviour-overlay.d.ts.map +1 -0
  7. package/dist/behaviour.d.ts +178 -3
  8. package/dist/behaviour.d.ts.map +1 -1
  9. package/dist/cli/handlers/scenario.d.ts.map +1 -1
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/lifecycle/scenario-eval.d.ts +23 -5
  13. package/dist/lifecycle/scenario-eval.d.ts.map +1 -1
  14. package/dist/lifecycle/scenario.d.ts +45 -3
  15. package/dist/lifecycle/scenario.d.ts.map +1 -1
  16. package/dist/lifecycle/types.d.ts +17 -0
  17. package/dist/lifecycle/types.d.ts.map +1 -1
  18. package/dist/op/activities/activity-contracts.d.ts +42 -0
  19. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  20. package/dist/op/activities/index.d.ts +2 -0
  21. package/dist/op/activities/index.d.ts.map +1 -1
  22. package/dist/op/activities/predict-behaviour.d.ts +207 -0
  23. package/dist/op/activities/predict-behaviour.d.ts.map +1 -0
  24. package/dist/op/activities/reconcile.d.ts +7 -0
  25. package/dist/op/activities/reconcile.d.ts.map +1 -1
  26. package/dist/op/composites/behaviour-op.d.ts +57 -0
  27. package/dist/op/composites/behaviour-op.d.ts.map +1 -0
  28. package/dist/op/composites/index.d.ts +2 -0
  29. package/dist/op/composites/index.d.ts.map +1 -1
  30. package/dist/op/index.d.ts +2 -2
  31. package/dist/op/index.d.ts.map +1 -1
  32. package/package.json +1 -1
  33. package/src/behaviour-delta.test.ts +331 -0
  34. package/src/behaviour-delta.ts +564 -0
  35. package/src/behaviour-http.test.ts +456 -0
  36. package/src/behaviour-http.ts +252 -0
  37. package/src/behaviour-overlay.test.ts +149 -0
  38. package/src/behaviour-overlay.ts +76 -0
  39. package/src/behaviour.test.ts +50 -0
  40. package/src/behaviour.ts +255 -3
  41. package/src/cli/handlers/scenario.test.ts +108 -0
  42. package/src/cli/handlers/scenario.ts +63 -16
  43. package/src/index.ts +2 -0
  44. package/src/lifecycle/scenario-cost.test.ts +183 -0
  45. package/src/lifecycle/scenario-eval.ts +133 -6
  46. package/src/lifecycle/scenario.ts +72 -4
  47. package/src/lifecycle/types.ts +17 -0
  48. package/src/lint/rules/op/ops012-activity-contract.test.ts +31 -0
  49. package/src/op/activities/activity-contracts.ts +49 -0
  50. package/src/op/activities/index.ts +24 -0
  51. package/src/op/activities/predict-behaviour.test.ts +255 -0
  52. package/src/op/activities/predict-behaviour.ts +468 -0
  53. package/src/op/activities/reconcile.ts +7 -2
  54. package/src/op/activity-contract-registry.test.ts +3 -0
  55. package/src/op/composites/behaviour-op.test.ts +56 -0
  56. package/src/op/composites/behaviour-op.ts +99 -0
  57. package/src/op/composites/index.ts +2 -0
  58. package/src/op/index.ts +2 -0
@@ -0,0 +1,564 @@
1
+ /**
2
+ * The predicted delta between two behaviour results, and how it is shown
3
+ * (#2358, contract #2356).
4
+ *
5
+ * `behaviour.ts` ships the invariant and deliberately not the presentation:
6
+ * {@link compareFigures} says on which axes two figures fail to be a delta of
7
+ * like things, and binds every consumer to mark such a pair wherever it is
8
+ * shown. This module is the consumer. It takes the base side and the head
9
+ * side of a pull request as two {@link BehaviourResult}s and produces one
10
+ * finding, under four rules that are the contract's and #2358's rather than
11
+ * this file's:
12
+ *
13
+ * 1. A whole-run refusal on either side is a finding that says **no
14
+ * prediction**, with the refusal's own text and remedy. Never a delta and
15
+ * never a partial one: the five refusal causes are the engine being
16
+ * absent, unreachable, out of credit, over quota, or a credential in the
17
+ * request, and none of them is a statement about the estate.
18
+ * 2. A delta is computed only over entities predicted on **both** sides. An
19
+ * entity declined on either side — `unpredicted`, whatever the reason —
20
+ * is a row in the finding saying so, not an absent row and not a zero. An
21
+ * estate that gained an unmapped kind between the two runs did not gain a
22
+ * cost, and a finding that read it as one would be the faked number the
23
+ * epic forbids.
24
+ * 3. Before any two figures are differenced, {@link compareFigures} runs. A
25
+ * non-empty mismatch set means the pair is **marked** with every label in
26
+ * it, in {@link FIGURE_MISMATCHES}' display order, and no number is
27
+ * subtracted. `modeled` minus `validated` is not a change in the estate.
28
+ * 4. Every figure shown carries its provenance — engine, version, tolerance,
29
+ * basis — and a rate is rendered as a rate: so much per hour, at the
30
+ * stated traffic level, never an amount.
31
+ *
32
+ * Resilience has a fifth rule, from #2360's third comment: a verdict is
33
+ * computed over the graph the engine was handed, and the declared path hands
34
+ * over reference edges with no containment. When either side's
35
+ * `meta.edgeCoverage` is `partial` or `unknown`, the two verdicts were reached
36
+ * over graphs of different completeness, and the finding shows each side's
37
+ * verdict and says why they are not compared rather than drawing an arrow
38
+ * between them.
39
+ *
40
+ * Pure: no I/O, no clock, no environment. The Op activity that posts the
41
+ * result (`./op/activities/predict-behaviour.ts`) is where the sides come
42
+ * from.
43
+ */
44
+
45
+ import {
46
+ FIGURE_MISMATCHES,
47
+ compareFigures,
48
+ isBehaviourRefusalReport,
49
+ isBehaviourResult,
50
+ isBehaviourUnpredictedReason,
51
+ renderBehaviourRefusal,
52
+ validateBehaviourBlock,
53
+ validateEdgeCoverage,
54
+ type BehaviourEdgeCoverage,
55
+ type BehaviourProvenance,
56
+ type BehaviourRefusal,
57
+ type BehaviourReport,
58
+ type BehaviourReportMeta,
59
+ type BehaviourResult,
60
+ type FigureMismatch,
61
+ type PredictedBehaviour,
62
+ type PredictedRate,
63
+ type UnpredictedEntity,
64
+ } from "./behaviour";
65
+
66
+ /* -------------------------------------------------------------------------- */
67
+ /* Validating a result on arrival */
68
+ /* -------------------------------------------------------------------------- */
69
+
70
+ /**
71
+ * Hold a {@link BehaviourResult} to the contract, on arrival.
72
+ *
73
+ * `behaviourReport` checks a report as the ordinary route builds it, and its
74
+ * own doc says plainly that this is a check and not a proof: the report type
75
+ * is a plain interface, `isBehaviourResult` accepts a hand-built one, and a
76
+ * lexicon calling the builder with `entityNames: Object.keys(entities)`
77
+ * self-certifies. A consumer that needs the guarantee runs this instead, with
78
+ * the names it asked about, and the two consumers that need it are the delta
79
+ * (each side arrives from a plugin) and a scenario's fixture (the block
80
+ * arrives from JSON on disk).
81
+ *
82
+ * Throws, naming what is wrong. The refusals are the builder's, applied to a
83
+ * result rather than to its parts: an entity in neither map or in both, a
84
+ * figure for a name nobody asked about, a block priced at a level the run did
85
+ * not ask for, an unpredicted reason outside the closed set, an edge-coverage
86
+ * claim that names no gap, and any block failing `validateBehaviourBlock`. A
87
+ * refusal arm is held to having a legal cause and a non-empty reason and
88
+ * remedy, since those two strings are what a consumer prints.
89
+ */
90
+ export function validateBehaviourResult(result: unknown, askedFor: readonly string[]): BehaviourResult {
91
+ if (!isBehaviourResult(result)) {
92
+ throw new Error(
93
+ "behaviour result is neither a report nor a refusal: expected `behaviour: \"v1\"` with either " +
94
+ "`refusal` or both `meta` and `entities`.",
95
+ );
96
+ }
97
+ if (isBehaviourRefusalReport(result)) {
98
+ const r = result.refusal;
99
+ if (!isBehaviourUnpredictedReason(r.cause)) {
100
+ throw new Error(`behaviour refusal carries the cause ${JSON.stringify(r.cause)}, which is not a legal reason.`);
101
+ }
102
+ if (typeof r.reason !== "string" || r.reason.trim() === "") {
103
+ throw new Error("behaviour refusal states no reason — the sentence a consumer prints is missing.");
104
+ }
105
+ if (typeof r.remedy !== "string" || r.remedy.trim() === "") {
106
+ throw new Error("behaviour refusal states no remedy — a refusal exists to be acted on.");
107
+ }
108
+ return result;
109
+ }
110
+
111
+ const report = result;
112
+ validateEdgeCoverage(report.meta.edgeCoverage);
113
+ if (typeof report.meta.at?.traffic !== "string" || report.meta.at.traffic.trim() === "") {
114
+ throw new Error("behaviour report states no traffic level in meta.at — a figure without its question is a bill in waiting.");
115
+ }
116
+ const asked = new Set(askedFor);
117
+ const unpredicted = report.unpredicted ?? {};
118
+ const holes = new Set(Object.keys(unpredicted));
119
+
120
+ for (const name of Object.keys(report.entities)) {
121
+ if (holes.has(name)) throw new Error(`behaviour report names "${name}" as both predicted and unpredicted.`);
122
+ if (!asked.has(name)) {
123
+ throw new Error(`behaviour report carries a figure for "${name}", which was not asked about.`);
124
+ }
125
+ validateBehaviourBlock(name, report.entities[name]);
126
+ if (report.entities[name].at.traffic !== report.meta.at.traffic) {
127
+ throw new Error(
128
+ `behaviour report priced "${name}" at ${JSON.stringify(report.entities[name].at.traffic)} in a run ` +
129
+ `whose meta.at.traffic is ${JSON.stringify(report.meta.at.traffic)}.`,
130
+ );
131
+ }
132
+ }
133
+ for (const name of holes) {
134
+ if (!asked.has(name)) throw new Error(`behaviour report names "${name}" unpredicted, and it was not asked about.`);
135
+ if (!isBehaviourUnpredictedReason(unpredicted[name]?.reason)) {
136
+ throw new Error(
137
+ `behaviour report gives "${name}" the reason ${JSON.stringify(unpredicted[name]?.reason)}, which is not a legal one.`,
138
+ );
139
+ }
140
+ }
141
+ const missing = askedFor.filter(
142
+ (name) => !Object.prototype.hasOwnProperty.call(report.entities, name) && !holes.has(name),
143
+ );
144
+ if (missing.length > 0) {
145
+ throw new Error(
146
+ `behaviour report gives no verdict at all for ${missing.map((n) => `"${n}"`).join(", ")}. Every entity ` +
147
+ "asked about lands in `entities` or in `unpredicted`; there is no third position.",
148
+ );
149
+ }
150
+ return report;
151
+ }
152
+
153
+ /* -------------------------------------------------------------------------- */
154
+ /* The delta */
155
+ /* -------------------------------------------------------------------------- */
156
+
157
+ /** One side of the delta: the result, and what to call it in the finding. */
158
+ export interface BehaviourDeltaSide {
159
+ /** `base` or `head`, or whatever the caller calls the two sides. */
160
+ label: string;
161
+ /** What the side was predicted from — a branch, a ref, a pull request. Free text. */
162
+ ref?: string;
163
+ result: BehaviourResult;
164
+ }
165
+
166
+ /** Why a row carries no plain difference. */
167
+ export type BehaviourDeltaRowKind =
168
+ /** Predicted on both sides and comparable: `deltaPerHour` is a difference in the estate. */
169
+ | "comparable"
170
+ /** Predicted on both sides and not a delta of like things: `mismatches` says on which axes. */
171
+ | "marked"
172
+ /** Declined on one side or both: `baseDeclined`/`headDeclined` say why. */
173
+ | "declined"
174
+ /** Present on the base side only — removed by the change. */
175
+ | "only-base"
176
+ /** Present on the head side only — added by the change. */
177
+ | "only-head";
178
+
179
+ /** One entity's row in the finding. */
180
+ export interface BehaviourDeltaRow {
181
+ name: string;
182
+ /** Declared entity type, when either side knows it. */
183
+ type?: string;
184
+ kind: BehaviourDeltaRowKind;
185
+ base?: PredictedBehaviour;
186
+ head?: PredictedBehaviour;
187
+ baseDeclined?: UnpredictedEntity;
188
+ headDeclined?: UnpredictedEntity;
189
+ /** Every axis the pair disagrees on, in display order. Non-empty exactly when `kind` is `marked`. */
190
+ mismatches?: FigureMismatch[];
191
+ /** `head.cost.perHour - base.cost.perHour`, present exactly when `kind` is `comparable`. */
192
+ deltaPerHour?: number;
193
+ /** The currency both comparable figures share. */
194
+ currency?: string;
195
+ }
196
+
197
+ /** chant's own sum of the comparable rows' deltas, per currency, labelled as such wherever it is shown. */
198
+ export interface ComparableDeltaSum {
199
+ currency: string;
200
+ perHour: number;
201
+ /** How many comparable pairs the sum is over. */
202
+ pairs: number;
203
+ }
204
+
205
+ /** A finding with figures on both sides. */
206
+ export interface BehaviourDeltaReport {
207
+ kind: "delta";
208
+ base: { label: string; ref?: string; meta: BehaviourReportMeta };
209
+ head: { label: string; ref?: string; meta: BehaviourReportMeta };
210
+ /** Every entity either side named, one row each, sorted by name. */
211
+ rows: BehaviourDeltaRow[];
212
+ /** The comparable rows' deltas summed by chant, per currency. Empty when no row is comparable. */
213
+ sums: ComparableDeltaSum[];
214
+ /**
215
+ * Whether the two sides' resilience verdicts were reached over graphs of the
216
+ * same completeness — both `meta.edgeCoverage.verdict === "complete"`. When
217
+ * false the finding shows each verdict and does not compare them.
218
+ */
219
+ resilienceComparable: boolean;
220
+ }
221
+
222
+ /** A finding with no figures, because one side or both refused. */
223
+ export interface BehaviourDeltaRefused {
224
+ kind: "no-prediction";
225
+ base: { label: string; ref?: string; refusal?: BehaviourRefusal };
226
+ head: { label: string; ref?: string; refusal?: BehaviourRefusal };
227
+ }
228
+
229
+ export type BehaviourDelta = BehaviourDeltaReport | BehaviourDeltaRefused;
230
+
231
+ /** Code-unit order, so two runs on two machines sort the rows the same way. */
232
+ function byCodeUnit(a: string, b: string): number {
233
+ return a < b ? -1 : a > b ? 1 : 0;
234
+ }
235
+
236
+ function has(map: Record<string, unknown> | undefined, key: string): boolean {
237
+ return map !== undefined && Object.prototype.hasOwnProperty.call(map, key);
238
+ }
239
+
240
+ /**
241
+ * Compute the delta. Pure, and never subtracts two figures it has not first
242
+ * put through {@link compareFigures}.
243
+ */
244
+ export function behaviourDelta(base: BehaviourDeltaSide, head: BehaviourDeltaSide): BehaviourDelta {
245
+ const baseRefused = isBehaviourRefusalReport(base.result);
246
+ const headRefused = isBehaviourRefusalReport(head.result);
247
+ if (baseRefused || headRefused) {
248
+ return {
249
+ kind: "no-prediction",
250
+ base: {
251
+ label: base.label,
252
+ ...(base.ref ? { ref: base.ref } : {}),
253
+ ...(baseRefused ? { refusal: (base.result as { refusal: BehaviourRefusal }).refusal } : {}),
254
+ },
255
+ head: {
256
+ label: head.label,
257
+ ...(head.ref ? { ref: head.ref } : {}),
258
+ ...(headRefused ? { refusal: (head.result as { refusal: BehaviourRefusal }).refusal } : {}),
259
+ },
260
+ };
261
+ }
262
+
263
+ const b = base.result as BehaviourReport;
264
+ const h = head.result as BehaviourReport;
265
+ const names = new Set<string>([
266
+ ...Object.keys(b.entities),
267
+ ...Object.keys(b.unpredicted ?? {}),
268
+ ...Object.keys(h.entities),
269
+ ...Object.keys(h.unpredicted ?? {}),
270
+ ]);
271
+
272
+ const rows: BehaviourDeltaRow[] = [];
273
+ const sumsByCurrency = new Map<string, ComparableDeltaSum>();
274
+
275
+ for (const name of [...names].sort(byCodeUnit)) {
276
+ const baseFigure = has(b.entities, name) ? b.entities[name] : undefined;
277
+ const headFigure = has(h.entities, name) ? h.entities[name] : undefined;
278
+ const baseDeclined = has(b.unpredicted, name) ? b.unpredicted![name] : undefined;
279
+ const headDeclined = has(h.unpredicted, name) ? h.unpredicted![name] : undefined;
280
+ const type = baseDeclined?.type ?? headDeclined?.type;
281
+ const row: BehaviourDeltaRow = {
282
+ name,
283
+ ...(type ? { type } : {}),
284
+ kind: "comparable",
285
+ ...(baseFigure ? { base: baseFigure } : {}),
286
+ ...(headFigure ? { head: headFigure } : {}),
287
+ ...(baseDeclined ? { baseDeclined } : {}),
288
+ ...(headDeclined ? { headDeclined } : {}),
289
+ };
290
+
291
+ if (baseDeclined || headDeclined) {
292
+ // Rule 2. A decline on either side is a row, and the row carries no
293
+ // difference — whatever the other side priced, there is nothing to
294
+ // difference it against.
295
+ row.kind = "declined";
296
+ } else if (baseFigure && headFigure) {
297
+ // Rule 3. Every axis, not the first; the set is the contract's.
298
+ const found = compareFigures(baseFigure, headFigure);
299
+ if (found.size > 0) {
300
+ row.kind = "marked";
301
+ row.mismatches = FIGURE_MISMATCHES.filter((m) => found.has(m));
302
+ } else {
303
+ row.kind = "comparable";
304
+ row.deltaPerHour = headFigure.cost.perHour - baseFigure.cost.perHour;
305
+ row.currency = headFigure.cost.currency;
306
+ const sum = sumsByCurrency.get(row.currency) ?? { currency: row.currency, perHour: 0, pairs: 0 };
307
+ sum.perHour += row.deltaPerHour;
308
+ sum.pairs += 1;
309
+ sumsByCurrency.set(row.currency, sum);
310
+ }
311
+ } else if (baseFigure) {
312
+ row.kind = "only-base";
313
+ } else {
314
+ row.kind = "only-head";
315
+ }
316
+ rows.push(row);
317
+ }
318
+
319
+ return {
320
+ kind: "delta",
321
+ base: { label: base.label, ...(base.ref ? { ref: base.ref } : {}), meta: b.meta },
322
+ head: { label: head.label, ...(head.ref ? { ref: head.ref } : {}), meta: h.meta },
323
+ rows,
324
+ sums: [...sumsByCurrency.values()].sort((x, y) => byCodeUnit(x.currency, y.currency)),
325
+ resilienceComparable:
326
+ b.meta.edgeCoverage.verdict === "complete" && h.meta.edgeCoverage.verdict === "complete",
327
+ };
328
+ }
329
+
330
+ /* -------------------------------------------------------------------------- */
331
+ /* Rendering */
332
+ /* -------------------------------------------------------------------------- */
333
+
334
+ /** What the rendered finding is about — the words in its heading. */
335
+ export interface BehaviourFindingContext {
336
+ /** The environment the prediction is for. */
337
+ env: string;
338
+ /** The Op that produced it, named in the heading so two Ops' findings read apart. */
339
+ op: string;
340
+ }
341
+
342
+ /**
343
+ * Four significant fractional digits at most, trailing zeros dropped. A rate
344
+ * of `0.272` renders as `0.272`, of `3` as `3`, and of `0.00230000001` as
345
+ * `0.0023`. No locale: the finding is the same bytes on every runner.
346
+ */
347
+ export function formatPerHour(n: number): string {
348
+ const fixed = n.toFixed(4);
349
+ return fixed.includes(".") ? fixed.replace(/0+$/, "").replace(/\.$/, "") : fixed;
350
+ }
351
+
352
+ /** A rate as a rate: `0.272 USD/hour`, never an amount. */
353
+ export function renderRate(rate: PredictedRate): string {
354
+ return `${formatPerHour(rate.perHour)} ${rate.currency}/hour`;
355
+ }
356
+
357
+ /** A signed difference, `+0.4 USD/hour`, `-0.1 USD/hour`, `0 USD/hour`. */
358
+ function renderDelta(perHour: number, currency: string): string {
359
+ const sign = perHour > 0 ? "+" : "";
360
+ return `${sign}${formatPerHour(perHour)} ${currency}/hour`;
361
+ }
362
+
363
+ /** `acme-sim 1.4.2 · ±15% · modeled` — the four provenance fields, always together. */
364
+ export function renderProvenance(p: BehaviourProvenance): string {
365
+ return `${p.engine} ${p.version} · ${p.tolerance} · ${p.basis}`;
366
+ }
367
+
368
+ function renderCoverage(c: BehaviourEdgeCoverage): string {
369
+ const gaps: string[] = [];
370
+ if (c.unresolvedKinds && c.unresolvedKinds.length > 0) gaps.push(`unresolved kinds: ${c.unresolvedKinds.join(", ")}`);
371
+ if (c.dangling && c.dangling.length > 0) gaps.push(`${c.dangling.length} dangling reference(s)`);
372
+ if (c.containmentEdges && c.containmentEdges.length > 0) gaps.push(`${c.containmentEdges.length} containment edge(s)`);
373
+ return gaps.length > 0 ? `${c.verdict} (${gaps.join("; ")})` : c.verdict;
374
+ }
375
+
376
+ function renderAxis(name: string, before: number | undefined, after: number | undefined): string {
377
+ const b = before === undefined ? "—" : formatPerHour(before);
378
+ const a = after === undefined ? "—" : formatPerHour(after);
379
+ return `${name} ${b} → ${a}`;
380
+ }
381
+
382
+ function renderHeadroom(base: PredictedBehaviour | undefined, head: PredictedBehaviour | undefined): string {
383
+ const b = (base?.headroom ?? {}) as { cpu?: number; latency?: number };
384
+ const h = (head?.headroom ?? {}) as { cpu?: number; latency?: number };
385
+ return [renderAxis("cpu", b.cpu, h.cpu), renderAxis("latency", b.latency, h.latency)].join(" · ");
386
+ }
387
+
388
+ function renderVerdict(f: PredictedBehaviour | undefined): string {
389
+ return f ? `${f.resilience.verdict} (${f.resilience.failure})` : "—";
390
+ }
391
+
392
+ function renderResilience(row: BehaviourDeltaRow, comparable: boolean): string {
393
+ const { base, head } = row;
394
+ if (!base || !head) return base ? `base ${renderVerdict(base)}` : head ? `head ${renderVerdict(head)}` : "—";
395
+ if (comparable && base.resilience.failure === head.resilience.failure) {
396
+ const arrow = base.resilience.verdict === head.resilience.verdict ? "=" : "→";
397
+ return `${base.resilience.verdict} ${arrow} ${head.resilience.verdict} (${base.resilience.failure})`;
398
+ }
399
+ return `base ${renderVerdict(base)}; head ${renderVerdict(head)}`;
400
+ }
401
+
402
+ function renderRowProvenance(row: BehaviourDeltaRow): string {
403
+ const { base, head } = row;
404
+ if (base && head) {
405
+ const b = renderProvenance(base.provenance);
406
+ const h = renderProvenance(head.provenance);
407
+ return b === h ? b : `base: ${b}; head: ${h}`;
408
+ }
409
+ return base ? renderProvenance(base.provenance) : head ? renderProvenance(head.provenance) : "—";
410
+ }
411
+
412
+ function cell(s: string): string {
413
+ return s.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
414
+ }
415
+
416
+ function figureCell(f: PredictedBehaviour | undefined, declined: UnpredictedEntity | undefined): string {
417
+ if (declined) return `declined: ${declined.reason}`;
418
+ if (f) return renderRate(f.cost);
419
+ return "—";
420
+ }
421
+
422
+ function deltaCell(row: BehaviourDeltaRow): string {
423
+ switch (row.kind) {
424
+ case "comparable":
425
+ return renderDelta(row.deltaPerHour as number, row.currency as string);
426
+ case "marked":
427
+ return `marked: ${(row.mismatches ?? []).join(", ")}`;
428
+ case "declined":
429
+ return "no delta (declined)";
430
+ case "only-base":
431
+ return "removed";
432
+ case "only-head":
433
+ return "added";
434
+ }
435
+ }
436
+
437
+ /**
438
+ * The finding as Markdown, for a pull-request comment or a merge-request
439
+ * note. Deterministic for a given delta.
440
+ *
441
+ * The first paragraph says what the numbers are, before any of them appear:
442
+ * a modeled rate for one hypothetical hour, at the level the run was asked
443
+ * for, from a named engine at a stated tolerance. The paragraph is not
444
+ * decoration; it is rule 1 of the contract applied to prose.
445
+ */
446
+ export function renderBehaviourFinding(delta: BehaviourDelta, ctx: BehaviourFindingContext): string {
447
+ const lines: string[] = [];
448
+
449
+ if (delta.kind === "no-prediction") {
450
+ lines.push(`## Predicted behaviour for \`${ctx.env}\` — no prediction (Op \`${ctx.op}\`)`, "");
451
+ lines.push(
452
+ "No delta is shown and no figure is guessed locally. A prediction refused on one side is not a " +
453
+ "prediction of zero on that side, so there is nothing to difference the other side against.",
454
+ "",
455
+ );
456
+ for (const side of [delta.base, delta.head]) {
457
+ if (!side.refusal) continue;
458
+ const title = side.ref ? `${side.label} (${side.ref})` : side.label;
459
+ lines.push(`**${title}**`, "", "```", renderBehaviourRefusal(side.refusal, { color: false }), "```", "");
460
+ }
461
+ return lines.join("\n").trimEnd() + "\n";
462
+ }
463
+
464
+ const traffic = delta.head.meta.at.traffic;
465
+ lines.push(`## Predicted behaviour for \`${ctx.env}\` at \`${traffic}\` (Op \`${ctx.op}\`)`, "");
466
+ lines.push(
467
+ "A prediction, not a measurement. Every figure below is one engine's modeled rate for one hypothetical " +
468
+ "hour at the stated traffic level, shown with that engine's name, version, stated tolerance and basis " +
469
+ "(`modeled` from list prices, or `validated`). Nothing here is an amount owed for an hour that happened.",
470
+ "",
471
+ );
472
+
473
+ lines.push("| Side | Predicted from | Engine | Traffic level | Edge coverage | Engine's own estate total |");
474
+ lines.push("|---|---|---|---|---|---|");
475
+ for (const side of [delta.base, delta.head]) {
476
+ const total = side.meta.total ? renderRate(side.meta.total) : "not stated";
477
+ lines.push(
478
+ `| ${side.label} | ${cell(side.ref ?? "—")} | ${cell(`${side.meta.engine} ${side.meta.version}`)} | ` +
479
+ `${cell(side.meta.at.traffic)} | ${cell(renderCoverage(side.meta.edgeCoverage))} | ${total} |`,
480
+ );
481
+ }
482
+ lines.push("");
483
+ if (delta.base.meta.at.traffic !== delta.head.meta.at.traffic) {
484
+ lines.push(
485
+ `The two sides were predicted at different traffic levels (\`${delta.base.meta.at.traffic}\` and ` +
486
+ `\`${delta.head.meta.at.traffic}\`), so every pair below is marked \`mixed-level\` and none is differenced.`,
487
+ "",
488
+ );
489
+ }
490
+ if (!delta.resilienceComparable) {
491
+ lines.push(
492
+ "Resilience verdicts are shown per side and **not compared**: edge coverage is " +
493
+ `\`${delta.base.meta.edgeCoverage.verdict}\` on ${delta.base.label} and ` +
494
+ `\`${delta.head.meta.edgeCoverage.verdict}\` on ${delta.head.label}, so the two verdicts were computed ` +
495
+ "over graphs of different completeness, and a difference between them is not a difference in the estate.",
496
+ "",
497
+ );
498
+ }
499
+
500
+ lines.push(
501
+ "| Entity | Type | " +
502
+ `${delta.base.label} | ${delta.head.label} | Delta per hour | Headroom (${delta.base.label} → ${delta.head.label}) | ` +
503
+ "Resilience | Provenance |",
504
+ );
505
+ lines.push("|---|---|---|---|---|---|---|---|");
506
+ for (const row of delta.rows) {
507
+ lines.push(
508
+ `| ${cell(row.name)} | ${cell(row.type ?? "")} | ${cell(figureCell(row.base, row.baseDeclined))} | ` +
509
+ `${cell(figureCell(row.head, row.headDeclined))} | ${cell(deltaCell(row))} | ` +
510
+ `${cell(renderHeadroom(row.base, row.head))} | ${cell(renderResilience(row, delta.resilienceComparable))} | ` +
511
+ `${cell(renderRowProvenance(row))} |`,
512
+ );
513
+ }
514
+ lines.push("");
515
+
516
+ const marked = delta.rows.filter((r) => r.kind === "marked").length;
517
+ const declined = delta.rows.filter((r) => r.kind === "declined").length;
518
+ const oneSided = delta.rows.filter((r) => r.kind === "only-base" || r.kind === "only-head").length;
519
+ if (delta.sums.length > 0) {
520
+ for (const sum of delta.sums) {
521
+ lines.push(
522
+ `Sum of the comparable deltas: **${renderDelta(sum.perHour, sum.currency)}** over ${sum.pairs} pair(s). ` +
523
+ "This is chant's own arithmetic over the comparable rows above and not an engine figure; it excludes " +
524
+ `${marked} marked pair(s), ${declined} declined entit${declined === 1 ? "y" : "ies"} and ${oneSided} ` +
525
+ "entit" + (oneSided === 1 ? "y" : "ies") + " present on one side only.",
526
+ );
527
+ }
528
+ lines.push("");
529
+ } else {
530
+ lines.push("No pair is comparable, so no delta is summed.", "");
531
+ }
532
+
533
+ const declinedRows = delta.rows.filter((r) => r.kind === "declined");
534
+ if (declinedRows.length > 0) {
535
+ lines.push("### Declined entities", "");
536
+ lines.push(
537
+ "An entity the engine could not price is reported, not priced at nothing. It carries no delta on either " +
538
+ "side; the reason is the lexicon's own.",
539
+ "",
540
+ );
541
+ for (const row of declinedRows) {
542
+ for (const [label, d] of [
543
+ [delta.base.label, row.baseDeclined],
544
+ [delta.head.label, row.headDeclined],
545
+ ] as const) {
546
+ if (!d) continue;
547
+ lines.push(`- \`${row.name}\` on ${label}: \`${d.reason}\`${d.detail ? ` — ${d.detail}` : ""}`);
548
+ }
549
+ }
550
+ lines.push("");
551
+ }
552
+
553
+ const hints = delta.rows.filter((r) => r.head?.rightSize);
554
+ if (hints.length > 0) {
555
+ lines.push(`### Right-size hints on ${delta.head.label}`, "");
556
+ for (const row of hints) {
557
+ const rs = row.head!.rightSize!;
558
+ lines.push(`- \`${row.name}\`: ${rs.suggestion}${rs.reason ? ` — ${rs.reason}` : ""}`);
559
+ }
560
+ lines.push("");
561
+ }
562
+
563
+ return lines.join("\n").trimEnd() + "\n";
564
+ }