@goodbones/campaigns 0.1.0 → 0.1.1-beta.2

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 (60) hide show
  1. package/build/dts/core/campaign-state.d.ts +3 -1
  2. package/build/dts/core/campaign-state.d.ts.map +1 -1
  3. package/build/dts/core/campaigns.d.ts +46 -2
  4. package/build/dts/core/campaigns.d.ts.map +1 -1
  5. package/build/dts/core/ledger.d.ts +58 -2
  6. package/build/dts/core/ledger.d.ts.map +1 -1
  7. package/build/dts/domain/config.d.ts +204 -2
  8. package/build/dts/domain/config.d.ts.map +1 -1
  9. package/build/dts/host/campaigns.d.ts +34 -0
  10. package/build/dts/host/campaigns.d.ts.map +1 -1
  11. package/build/dts/host/diff.d.ts +1 -0
  12. package/build/dts/host/diff.d.ts.map +1 -1
  13. package/build/dts/host/measure-command.d.ts +3 -0
  14. package/build/dts/host/measure-command.d.ts.map +1 -0
  15. package/build/dts/index.d.ts +5 -4
  16. package/build/dts/index.d.ts.map +1 -1
  17. package/build/dts/load/extension.d.ts +2 -1
  18. package/build/dts/load/extension.d.ts.map +1 -1
  19. package/build/dts/manifest/lower.d.ts.map +1 -1
  20. package/build/dts/manifest/spec.d.ts +209 -3
  21. package/build/dts/manifest/spec.d.ts.map +1 -1
  22. package/build/dts/ports/campaign-measure.d.ts +3 -0
  23. package/build/dts/ports/campaign-measure.d.ts.map +1 -0
  24. package/build/esm/core/campaign-state.js +35 -2
  25. package/build/esm/core/campaign-state.js.map +1 -1
  26. package/build/esm/core/campaigns.js +177 -2
  27. package/build/esm/core/campaigns.js.map +1 -1
  28. package/build/esm/core/ledger.js +215 -0
  29. package/build/esm/core/ledger.js.map +1 -1
  30. package/build/esm/domain/config.js +40 -4
  31. package/build/esm/domain/config.js.map +1 -1
  32. package/build/esm/host/campaigns.js +423 -29
  33. package/build/esm/host/campaigns.js.map +1 -1
  34. package/build/esm/host/diff.js +24 -0
  35. package/build/esm/host/diff.js.map +1 -1
  36. package/build/esm/host/measure-command.js +35 -0
  37. package/build/esm/host/measure-command.js.map +1 -0
  38. package/build/esm/index.js +2 -2
  39. package/build/esm/index.js.map +1 -1
  40. package/build/esm/load/extension.js +57 -13
  41. package/build/esm/load/extension.js.map +1 -1
  42. package/build/esm/manifest/lower.js +68 -1
  43. package/build/esm/manifest/lower.js.map +1 -1
  44. package/build/esm/manifest/spec.js +85 -5
  45. package/build/esm/manifest/spec.js.map +1 -1
  46. package/build/esm/ports/campaign-measure.js +2 -0
  47. package/build/esm/ports/campaign-measure.js.map +1 -0
  48. package/package.json +2 -2
  49. package/src/core/campaign-state.ts +49 -2
  50. package/src/core/campaigns.ts +232 -3
  51. package/src/core/ledger.ts +264 -2
  52. package/src/domain/config.ts +46 -4
  53. package/src/host/campaigns.ts +573 -33
  54. package/src/host/diff.ts +29 -0
  55. package/src/host/measure-command.ts +36 -0
  56. package/src/index.ts +31 -0
  57. package/src/load/extension.ts +70 -6
  58. package/src/manifest/lower.ts +80 -1
  59. package/src/manifest/spec.ts +100 -5
  60. package/src/ports/campaign-measure.ts +11 -0
@@ -28,12 +28,16 @@ import type {
28
28
  CampaignUnit,
29
29
  Detector,
30
30
  Holdout,
31
+ Measure,
32
+ MeasureDirection,
33
+ MeasureSource,
31
34
  ObjectiveRule,
32
35
  OnTouch,
33
36
  PerimeterRule,
34
37
  PhaseRule,
35
38
  ReportFormat,
36
39
  } from "../domain/config.js";
40
+ import type { CampaignMeasure } from "../ports/campaign-measure.js";
37
41
  import type { CampaignPredicate, Range } from "../ports/campaign-predicate.js";
38
42
  import type { ReportSource, ReportSpec } from "../ports/report-source.js";
39
43
 
@@ -139,19 +143,48 @@ export type CompiledSectorTerm =
139
143
  | { readonly kind: "oneRoot" }
140
144
  | { readonly kind: "oneHost"; readonly hosts: ReadonlyArray<RegExp> };
141
145
 
146
+ // Where a scalar objective's number comes from, per file. A `report` source
147
+ // is its report term compiled as a detector and counted at `match`, and an
148
+ // `fn` source is an `fn` detector read as a number — so both reach the
149
+ // function loading, the report pre-reading and the syntax parse by the
150
+ // paths every detector does.
151
+ export type CompiledMeasureSource =
152
+ | { readonly kind: "lines" }
153
+ | { readonly kind: "files" }
154
+ | { readonly kind: "report"; readonly detect: CompiledDetector }
155
+ | { readonly kind: "fn"; readonly detect: CompiledDetector & { readonly kind: "fn" } };
156
+
157
+ export type CompiledMeasure =
158
+ | { readonly kind: "sum"; readonly of: CompiledMeasureSource }
159
+ | {
160
+ readonly kind: "ratio";
161
+ readonly of: CompiledMeasureSource;
162
+ readonly per: CompiledMeasureSource;
163
+ readonly scale: number;
164
+ }
165
+ | { readonly kind: "command"; readonly command: string; readonly pattern: RegExp | null };
166
+
142
167
  export type CompiledObjective = {
143
168
  readonly name: string;
144
169
  readonly id: string;
145
170
  readonly campaign: string;
146
171
  readonly message: string;
147
172
  readonly why: string | null;
148
- readonly holdout: Holdout;
173
+ // `null` for a scalar objective, which holds nothing out.
174
+ readonly holdout: Holdout | null;
149
175
  // The unit a per-file detector answers at; `declaration` for the
150
176
  // objectives no per-file detector answers, whose entries are `file#subject`.
151
177
  readonly unit: CampaignUnit;
152
178
  readonly detect: CompiledDetector | null;
153
179
  readonly sector: CompiledSectorTerm | null;
154
180
  readonly endState: { readonly phase: string; readonly family: string } | null;
181
+ // A scalar objective: its number, which way is better, the slack either
182
+ // side of the record, and the value at which it is met (`null` for a
183
+ // standing measure, which only ratchets).
184
+ readonly measure: CompiledMeasure | null;
185
+ readonly direction: MeasureDirection;
186
+ readonly tolerance: number;
187
+ readonly target: number | null;
155
188
  readonly until: string | null;
156
189
  readonly probes: {
157
190
  readonly fires: ReadonlyArray<CampaignProbe>;
@@ -376,6 +409,51 @@ const compileDetector = (
376
409
  return Result.succeed({ kind: "fn", name: detector.fn });
377
410
  };
378
411
 
412
+ const compileMeasureSource = (
413
+ name: string,
414
+ source: MeasureSource,
415
+ ): Result.Result<CompiledMeasureSource, PatternInvalid> => {
416
+ if ("lines" in source) return Result.succeed({ kind: "lines" });
417
+ if ("files" in source) return Result.succeed({ kind: "files" });
418
+ if ("fn" in source)
419
+ return Result.succeed({ kind: "fn", detect: { kind: "fn", name: source.fn } });
420
+ const detect = compileDetector(name, source);
421
+ return Result.isFailure(detect)
422
+ ? Result.fail(detect.failure)
423
+ : Result.succeed({ kind: "report", detect: detect.success });
424
+ };
425
+
426
+ const compileMeasure = (
427
+ name: string,
428
+ measure: Measure,
429
+ ): Result.Result<CompiledMeasure, PatternInvalid> => {
430
+ if ("command" in measure) {
431
+ const pattern = compilePatterns(name, "measure.pattern", measure.pattern);
432
+ if (Result.isFailure(pattern)) return Result.fail(pattern.failure);
433
+ return Result.succeed({
434
+ kind: "command",
435
+ command: measure.command,
436
+ pattern: pattern.success[0] ?? null,
437
+ });
438
+ }
439
+ if ("ratio" in measure) {
440
+ const of = compileMeasureSource(name, measure.ratio.of);
441
+ if (Result.isFailure(of)) return Result.fail(of.failure);
442
+ const per = compileMeasureSource(name, measure.ratio.per);
443
+ if (Result.isFailure(per)) return Result.fail(per.failure);
444
+ return Result.succeed({
445
+ kind: "ratio",
446
+ of: of.success,
447
+ per: per.success,
448
+ scale: measure.ratio.scale,
449
+ });
450
+ }
451
+ const of = compileMeasureSource(name, measure);
452
+ return Result.isFailure(of)
453
+ ? Result.fail(of.failure)
454
+ : Result.succeed({ kind: "sum", of: of.success });
455
+ };
456
+
379
457
  export const compileObjective = (
380
458
  rule: ObjectiveRule,
381
459
  ): Result.Result<CompiledObjective, PatternInvalid> => {
@@ -399,17 +477,28 @@ export const compileObjective = (
399
477
  sector = { kind: "oneHost", hosts: hosts.success };
400
478
  }
401
479
  }
480
+ let measure: CompiledMeasure | null = null;
481
+ if (rule.measure !== undefined) {
482
+ const compiled = compileMeasure(rule.name, rule.measure);
483
+ if (Result.isFailure(compiled)) return Result.fail(compiled.failure);
484
+ measure = compiled.success;
485
+ }
486
+ const holdout = rule.holdout ?? null;
402
487
  return Result.succeed({
403
488
  name: rule.name,
404
489
  id: rule.id,
405
490
  campaign: rule.campaign,
406
491
  message: rule.message,
407
492
  why: rule.why ?? null,
408
- holdout: rule.holdout,
409
- unit: rule.holdout === "sector" ? "declaration" : rule.holdout,
493
+ holdout,
494
+ unit: holdout === null || holdout === "sector" ? "declaration" : holdout,
410
495
  detect,
411
496
  sector,
412
497
  endState: rule.endState ?? null,
498
+ measure,
499
+ direction: rule.direction ?? "down",
500
+ tolerance: rule.tolerance ?? 0,
501
+ target: rule.target ?? null,
413
502
  until: rule.until ?? null,
414
503
  probes: rule.probes,
415
504
  });
@@ -1087,6 +1176,7 @@ export const reportSpecsOf = (
1087
1176
  for (const objective of rule.objectives) {
1088
1177
  if (objective.detect !== null) walk(objective.detect);
1089
1178
  if (objective.sector?.kind === "has") walk(objective.sector.detect);
1179
+ for (const detect of measureDetectorsOf(objective)) walk(detect);
1090
1180
  }
1091
1181
  if (rule.perimeter?.kind === "match") walk(rule.perimeter.detect);
1092
1182
  }
@@ -1098,6 +1188,119 @@ export const evaluateObjectives = (
1098
1188
  input: CampaignInput,
1099
1189
  ): ReadonlyArray<CampaignHit> => selected.flatMap((rule) => evaluateObjective(rule, input));
1100
1190
 
1191
+ // ---------------------------------------------------------------------------
1192
+ // Scalar objectives: a number per file, summed per sector.
1193
+
1194
+ const measureSourcesOf = (measure: CompiledMeasure): ReadonlyArray<CompiledMeasureSource> =>
1195
+ measure.kind === "sum" ? [measure.of] : measure.kind === "ratio" ? [measure.of, measure.per] : [];
1196
+
1197
+ // The detectors a scalar objective reads a file through: its report and
1198
+ // function sources, so the loader holds its functions, the host reads its
1199
+ // reports ahead and parses the file when either needs the tree.
1200
+ export const measureDetectorsOf = (
1201
+ objective: CompiledObjective,
1202
+ ): ReadonlyArray<CompiledDetector> =>
1203
+ objective.measure === null
1204
+ ? []
1205
+ : measureSourcesOf(objective.measure).flatMap((source) =>
1206
+ source.kind === "report" || source.kind === "fn" ? [source.detect] : [],
1207
+ );
1208
+
1209
+ // The scalar objectives a host measures file by file: all but a command's,
1210
+ // which measures the repository once.
1211
+ export const perFileMeasuresOf = (rule: CompiledCampaign): ReadonlyArray<CompiledObjective> =>
1212
+ rule.objectives.filter(
1213
+ (objective) => objective.measure !== null && objective.measure.kind !== "command",
1214
+ );
1215
+
1216
+ // Six decimals: enough for a density, and few enough that a ratio reads the
1217
+ // same on every run and the ledger's arithmetic compares exactly.
1218
+ export const roundMeasure = (value: number): number => Math.round(value * 1e6) / 1e6;
1219
+
1220
+ const NON_BLANK = /\S/;
1221
+
1222
+ const measureSource = (source: CompiledMeasureSource, input: CampaignInput): number => {
1223
+ switch (source.kind) {
1224
+ case "lines": {
1225
+ let lines = 0;
1226
+ for (const line of input.text.split("\n")) if (NON_BLANK.test(line)) lines += 1;
1227
+ return lines;
1228
+ }
1229
+ case "files":
1230
+ return 1;
1231
+ case "report":
1232
+ return candidatesOf(source.detect, "match", input).length;
1233
+ case "fn": {
1234
+ // The loader holds whatever the manifest named; a measure reads its
1235
+ // answer as a number, and anything else — a boolean, a list, a
1236
+ // negative or non-finite number — is `NaN`, which `check` refuses.
1237
+ const found = input.functions.get(source.detect.name) as
1238
+ ((...args: Parameters<CampaignMeasure>) => unknown) | undefined;
1239
+ if (found === undefined) return Number.NaN;
1240
+ const answer = found({
1241
+ file: input.file,
1242
+ text: input.text,
1243
+ facts: input.facts,
1244
+ syntax: input.syntax,
1245
+ });
1246
+ return typeof answer === "number" && Number.isFinite(answer) && answer >= 0
1247
+ ? answer
1248
+ : Number.NaN;
1249
+ }
1250
+ }
1251
+ };
1252
+
1253
+ // What one file contributes: to the sum, or to a ratio's two sums.
1254
+ export type MeasureParts = { readonly of: number; readonly per: number };
1255
+
1256
+ export const ZERO_PARTS: MeasureParts = { of: 0, per: 0 };
1257
+
1258
+ export const measureFile = (measure: CompiledMeasure, input: CampaignInput): MeasureParts =>
1259
+ measure.kind === "ratio"
1260
+ ? { of: measureSource(measure.of, input), per: measureSource(measure.per, input) }
1261
+ : measure.kind === "sum"
1262
+ ? { of: measureSource(measure.of, input), per: 0 }
1263
+ : ZERO_PARTS;
1264
+
1265
+ export const addParts = (left: MeasureParts, right: MeasureParts): MeasureParts => ({
1266
+ of: left.of + right.of,
1267
+ per: left.per + right.per,
1268
+ });
1269
+
1270
+ // A sector's value from its files' parts: the sum, or `scale × Σof / Σper`
1271
+ // (0 where the denominator is).
1272
+ export const valueOfParts = (measure: CompiledMeasure, parts: MeasureParts): number => {
1273
+ if (measure.kind !== "ratio") return roundMeasure(parts.of);
1274
+ if (parts.per === 0) return Number.isNaN(parts.of) ? Number.NaN : 0;
1275
+ return roundMeasure((measure.scale * parts.of) / parts.per);
1276
+ };
1277
+
1278
+ // A command's output as a number: the whole of it, trimmed, or the `value`
1279
+ // group of the first match of the pattern. `NaN` when neither reads as one.
1280
+ export const numberFromOutput = (measure: CompiledMeasure, output: string): number => {
1281
+ if (measure.kind !== "command") return Number.NaN;
1282
+ const text =
1283
+ measure.pattern === null ? output.trim() : measure.pattern.exec(output)?.groups?.value;
1284
+ if (text === undefined || text.trim() === "") return Number.NaN;
1285
+ const value = Number(text.trim());
1286
+ return Number.isFinite(value) ? roundMeasure(value) : Number.NaN;
1287
+ };
1288
+
1289
+ // How far a scalar is from being met: the distance to its target in the
1290
+ // direction it improves, 0 once there — and 0 for a standing measure, which
1291
+ // has no target and is never residue. This is the scalar's dimension of the
1292
+ // residue vector, so a phase is left when it reaches 0 as when a holdout
1293
+ // count does.
1294
+ export const distanceToTarget = (objective: CompiledObjective, value: number): number => {
1295
+ if (objective.target === null) return 0;
1296
+ // A number that did not measure holds the sector where it is — `check`
1297
+ // refuses it — rather than letting it pass a phase it has not met.
1298
+ if (Number.isNaN(value)) return 1;
1299
+ const distance =
1300
+ objective.direction === "down" ? value - objective.target : objective.target - value;
1301
+ return Math.max(0, roundMeasure(distance));
1302
+ };
1303
+
1101
1304
  // One line per leaf term: how it reads, and what it answered for this file —
1102
1305
  // every leaf evaluated, with no short-circuit, since the point is to show
1103
1306
  // which one is not saying what the author thinks it says.
@@ -1239,6 +1442,8 @@ export type FailedProbe = {
1239
1442
  readonly admittedBy?: string;
1240
1443
  // For a probe outside the campaign's own scope.
1241
1444
  readonly outOfScope?: boolean;
1445
+ // For a scalar objective's probe: what the file measured.
1446
+ readonly measured?: number;
1242
1447
  };
1243
1448
 
1244
1449
  // Every objective must fire on each of its `fires` probes and stay silent
@@ -1283,6 +1488,30 @@ export const campaignsFailingTheirProbe = (
1283
1488
  return input;
1284
1489
  };
1285
1490
  for (const objective of rule.objectives) {
1491
+ const measure = objective.measure;
1492
+ if (measure !== null) {
1493
+ // A scalar's probe is proven on the number the file contributes:
1494
+ // the probe's `value` when it states one, above zero when it does
1495
+ // not, and zero for an `ignores` probe.
1496
+ const prove = (probe: CampaignProbe, expected: "fires" | "ignores"): void => {
1497
+ if (!anyMatches(rule.scope, probe.path)) {
1498
+ failed.push({ name: objective.name, probe, expected, outOfScope: true });
1499
+ return;
1500
+ }
1501
+ const input = probeInputOf(probe, extractor, matcherFor(probe.path), functions);
1502
+ const measured = valueOfParts(measure, measureFile(measure, input));
1503
+ const holds =
1504
+ expected === "ignores"
1505
+ ? measured === 0
1506
+ : probe.value === undefined
1507
+ ? measured > 0
1508
+ : measured === roundMeasure(probe.value);
1509
+ if (!holds) failed.push({ name: objective.name, probe, expected, measured });
1510
+ };
1511
+ for (const probe of objective.probes.fires) prove(probe, "fires");
1512
+ for (const probe of objective.probes.ignores) prove(probe, "ignores");
1513
+ continue;
1514
+ }
1286
1515
  const detect = detectorOf(objective);
1287
1516
  if (detect === null) continue;
1288
1517
  const unit = objective.sector?.kind === "has" ? "file" : objective.unit;
@@ -1,8 +1,9 @@
1
+ import { type Standing, standingOf } from "@goodbones/core";
1
2
  import * as Result from "effect/Result";
2
3
  import * as Schema from "effect/Schema";
3
4
 
4
- import type { CampaignUnit } from "../domain/config.js";
5
- import type { CompiledCampaign } from "./campaigns.js";
5
+ import type { CampaignUnit, MeasureDirection } from "../domain/config.js";
6
+ import { type CompiledCampaign, roundMeasure } from "./campaigns.js";
6
7
  import { isDefinedPhase, type SectorPosition } from "./phases.js";
7
8
  import { IMPLICIT_SECTOR } from "./sectors.js";
8
9
 
@@ -447,6 +448,267 @@ export const isStalled = (
447
448
  return now - Date.parse(last) > rule.staleAfter;
448
449
  };
449
450
 
451
+ // ---------------------------------------------------------------------------
452
+ // A scalar objective's ledger: a number per sector, the best value `clear`
453
+ // has recorded for it, and every rise conceded. The same file, a second
454
+ // schema — told apart by `kind`, which the holdout ledger does not carry, so
455
+ // every ledger written before scalars existed decodes as it did.
456
+ //
457
+ // - `check` holds each sector in window to `recorded`, within the
458
+ // objective's tolerance: a value worse by more is unrecorded growth,
459
+ // and a value better by more is progress `clear` has not written yet —
460
+ // stale, as a holdout that stopped firing is.
461
+ // - `clear` writes an improvement: `recorded` moves to the value and the
462
+ // difference joins `improved`. `concede` writes a rise, with a reason.
463
+ // - The arithmetic, per sector, in the direction that is worse:
464
+ // `recorded = initial + Σ rise − improved`.
465
+ // - A sector leaving the window is `closed` at the value it left with;
466
+ // nothing after that counts.
467
+
468
+ const MeasureSectorLedger = Schema.Struct({
469
+ entered: Schema.String,
470
+ // The value the day the sector entered the window.
471
+ initial: Schema.Finite,
472
+ // The value the sector is held to.
473
+ recorded: Schema.Finite,
474
+ // Every improvement `clear` recorded, summed, as a magnitude.
475
+ improved: Schema.Finite,
476
+ // The value the sector left the window with, or `null` while inside it.
477
+ closed: Schema.NullOr(Schema.Finite),
478
+ // When `recorded` last improved — what the stall clock reads.
479
+ lastImproved: Schema.String,
480
+ });
481
+
482
+ // A rise conceded with a reason, or a re-baseline a phase concession
483
+ // authorized: the recorded value before and after.
484
+ const MeasureConcession = Schema.Struct({
485
+ sector: Schema.String,
486
+ at: Schema.String,
487
+ by: Schema.String,
488
+ from: Schema.Finite,
489
+ to: Schema.Finite,
490
+ reason: Schema.String,
491
+ });
492
+
493
+ export const MeasureLedger = Schema.Struct({
494
+ version: Schema.Literal(2),
495
+ kind: Schema.Literal("measure"),
496
+ campaign: Schema.String,
497
+ objective: Schema.String,
498
+ created: Schema.String,
499
+ direction: Schema.Literals(["down", "up"]),
500
+ sectors: Schema.Record(Schema.String, MeasureSectorLedger),
501
+ concessions: Schema.Array(MeasureConcession),
502
+ });
503
+
504
+ export type MeasureLedger = typeof MeasureLedger.Type;
505
+ export type MeasureSectorLedger = typeof MeasureSectorLedger.Type;
506
+ export type MeasureConcession = typeof MeasureConcession.Type;
507
+
508
+ const decodeMeasure = Schema.decodeUnknownResult(MeasureLedger, {
509
+ errors: "all",
510
+ onExcessProperty: "error",
511
+ });
512
+
513
+ export const isMeasureLedger = (raw: unknown): boolean =>
514
+ typeof raw === "object" &&
515
+ raw !== null &&
516
+ (raw as { readonly kind?: unknown }).kind === "measure";
517
+
518
+ export const decodeMeasureLedger = (raw: unknown): Result.Result<MeasureLedger, string> => {
519
+ const decoded = decodeMeasure(raw);
520
+ return Result.isFailure(decoded)
521
+ ? Result.fail(String(decoded.failure.issue))
522
+ : Result.succeed(decoded.success);
523
+ };
524
+
525
+ export const EMPTY_MEASURE_LEDGER = (
526
+ campaign: string,
527
+ objective: string,
528
+ direction: MeasureDirection,
529
+ now: number,
530
+ ): MeasureLedger => ({
531
+ version: 2,
532
+ kind: "measure",
533
+ campaign,
534
+ objective,
535
+ created: new Date(now).toISOString(),
536
+ direction,
537
+ sectors: {},
538
+ concessions: [],
539
+ });
540
+
541
+ export const serializeMeasureLedger = (ledger: MeasureLedger): string =>
542
+ `${JSON.stringify(ledger, null, 2)}\n`;
543
+
544
+ // How much worse `to` is than `from`, in the ledger's direction.
545
+ const worseBy = (direction: MeasureDirection, from: number, to: number): number =>
546
+ direction === "down" ? to - from : from - to;
547
+
548
+ // Sums of rounded numbers carry float error; the arithmetic is exact to
549
+ // well under the six decimals a value is measured to.
550
+ const EPSILON = 1e-6;
551
+
552
+ export const measureArithmeticHolds = (ledger: MeasureLedger, sector: string): boolean => {
553
+ const risen = ledger.concessions
554
+ .filter((one) => one.sector === sector)
555
+ .reduce((sum, one) => sum + worseBy(ledger.direction, one.from, one.to), 0);
556
+ const own = ledger.sectors[sector];
557
+ if (own === undefined) return Math.abs(risen) < EPSILON;
558
+ const expected = worseBy(ledger.direction, own.initial, own.recorded);
559
+ return Math.abs(expected - (risen - own.improved)) < EPSILON;
560
+ };
561
+
562
+ export const measureLedgerArithmeticHolds = (ledger: MeasureLedger): boolean =>
563
+ [
564
+ ...new Set([...Object.keys(ledger.sectors), ...ledger.concessions.map((one) => one.sector)]),
565
+ ].every((sector) => measureArithmeticHolds(ledger, sector));
566
+
567
+ // Where a sector's value stands against what its ledger holds it to.
568
+ export const measureStandingOf = (
569
+ ledger: MeasureLedger,
570
+ sector: string,
571
+ value: number,
572
+ tolerance: number,
573
+ ): Standing | "unrecorded" => {
574
+ const own = ledger.sectors[sector];
575
+ if (own === undefined || own.closed !== null) return "unrecorded";
576
+ return standingOf({ direction: ledger.direction, limit: own.recorded, tolerance }, value);
577
+ };
578
+
579
+ // `clear` for one sector, in one of three positions: entering the window
580
+ // (recorded at its value), inside it (an improvement past the tolerance
581
+ // becomes the record), or past it (closed at the value it left with).
582
+ export const clearedMeasure = (
583
+ ledger: MeasureLedger,
584
+ sector: string,
585
+ value: number,
586
+ tolerance: number,
587
+ now: number,
588
+ window: "inside" | "outside",
589
+ by = "objectives clear",
590
+ ): MeasureLedger => {
591
+ const at = new Date(now).toISOString();
592
+ const own = ledger.sectors[sector];
593
+ if (window === "outside") {
594
+ if (own === undefined || own.closed !== null) return ledger;
595
+ return {
596
+ ...ledger,
597
+ sectors: {
598
+ ...ledger.sectors,
599
+ [sector]: { ...own, closed: Number.isNaN(value) ? own.recorded : value },
600
+ },
601
+ };
602
+ }
603
+ if (Number.isNaN(value)) return ledger;
604
+ if (own === undefined) {
605
+ return {
606
+ ...ledger,
607
+ sectors: {
608
+ ...ledger.sectors,
609
+ [sector]: {
610
+ entered: at,
611
+ initial: value,
612
+ recorded: value,
613
+ improved: 0,
614
+ closed: null,
615
+ lastImproved: at,
616
+ },
617
+ },
618
+ };
619
+ }
620
+ if (own.closed !== null) {
621
+ // A sector born again after it was closed — its marker restored, say —
622
+ // enters again at its value, and the move from its old record is a
623
+ // concession, so the arithmetic still holds and the jump is in the file.
624
+ return {
625
+ ...ledger,
626
+ sectors: { ...ledger.sectors, [sector]: { ...own, recorded: value, closed: null } },
627
+ concessions:
628
+ own.recorded === value
629
+ ? ledger.concessions
630
+ : [
631
+ ...ledger.concessions,
632
+ { sector, at, by, from: own.recorded, to: value, reason: "entered the window again" },
633
+ ],
634
+ };
635
+ }
636
+ if (
637
+ standingOf({ direction: ledger.direction, limit: own.recorded, tolerance }, value) !==
638
+ "surpassed"
639
+ ) {
640
+ return ledger;
641
+ }
642
+ return {
643
+ ...ledger,
644
+ sectors: {
645
+ ...ledger.sectors,
646
+ [sector]: {
647
+ ...own,
648
+ recorded: value,
649
+ improved: roundMeasure(own.improved - worseBy(ledger.direction, own.recorded, value)),
650
+ lastImproved: at,
651
+ },
652
+ },
653
+ };
654
+ };
655
+
656
+ // `concede`, or a re-baseline: the sector is held to its value from now,
657
+ // and a concession records the move with its reason. A sector the ledger
658
+ // has not recorded is `clear`'s to enter, not a concession's.
659
+ export const concededMeasure = (
660
+ ledger: MeasureLedger,
661
+ sector: string,
662
+ value: number,
663
+ record: ConcessionRecord,
664
+ ): MeasureLedger => {
665
+ const own = ledger.sectors[sector];
666
+ if (own === undefined || own.closed !== null || Number.isNaN(value)) return ledger;
667
+ if (own.recorded === value) return ledger;
668
+ return {
669
+ ...ledger,
670
+ sectors: { ...ledger.sectors, [sector]: { ...own, recorded: value } },
671
+ concessions: [
672
+ ...ledger.concessions,
673
+ {
674
+ sector,
675
+ at: new Date(record.at).toISOString(),
676
+ by: record.by,
677
+ from: own.recorded,
678
+ to: value,
679
+ reason: record.reason,
680
+ },
681
+ ],
682
+ };
683
+ };
684
+
685
+ // What the ledger holds the open sectors to, summed.
686
+ export const recordedOf = (ledger: MeasureLedger): number =>
687
+ roundMeasure(
688
+ Object.values(ledger.sectors)
689
+ .filter((one) => one.closed === null)
690
+ .reduce((sum, one) => sum + one.recorded, 0),
691
+ );
692
+
693
+ export const lastImprovedOf = (ledger: MeasureLedger): string | null => {
694
+ const stamps = Object.values(ledger.sectors).map((one) => one.lastImproved);
695
+ return stamps.length === 0 ? null : stamps.reduce((a, b) => (a > b ? a : b));
696
+ };
697
+
698
+ // How far the open sectors have come from where they entered toward the
699
+ // target: `1` at it, `0` where they started. A standing measure has no
700
+ // target to come toward, and reads as its improvement over its initial.
701
+ export const measureProgressOf = (ledger: MeasureLedger, target: number | null): number => {
702
+ const open = Object.values(ledger.sectors).filter((one) => one.closed === null);
703
+ if (open.length === 0) return 1;
704
+ const initial = open.reduce((sum, one) => sum + one.initial, 0);
705
+ const recorded = open.reduce((sum, one) => sum + one.recorded, 0);
706
+ const goal = target === null ? 0 : target * open.length;
707
+ const span = worseBy(ledger.direction, goal, initial);
708
+ if (span <= 0) return 1;
709
+ return Math.min(1, Math.max(0, 1 - worseBy(ledger.direction, goal, recorded) / span));
710
+ };
711
+
450
712
  // ---------------------------------------------------------------------------
451
713
  // The per-sector record: what derivation cannot say about a sector — the
452
714
  // furthest phase it has reached, the phases attested for it, and the notes
@@ -182,6 +182,9 @@ export const CampaignProbe = Schema.Struct({
182
182
  edges: Schema.optionalKey(Schema.Record(Schema.String, ImportProbeTarget)),
183
183
  files: Schema.optionalKey(Schema.Array(Schema.String)),
184
184
  report: Schema.optionalKey(Schema.Array(ProbeDiagnostic)),
185
+ // A scalar objective's probe only: the number the file contributes. A
186
+ // `fires` probe without one asserts only that it is above zero.
187
+ value: Schema.optionalKey(Schema.Finite),
185
188
  });
186
189
 
187
190
  export const CampaignProbes = Schema.Struct({
@@ -189,12 +192,43 @@ export const CampaignProbes = Schema.Struct({
189
192
  ignores: Schema.Array(CampaignProbe),
190
193
  });
191
194
 
195
+ // Where a scalar objective's number comes from, per file: its non-blank
196
+ // lines, the file itself (so a sector's value is its file count), the
197
+ // diagnostics a report puts on it, or what a `module#export` function
198
+ // answers for it. A sector's value is the sum over its files.
199
+ export const MeasureSource = Schema.Union([
200
+ Schema.Struct({ lines: Schema.Literal(true) }),
201
+ Schema.Struct({ files: Schema.Literal(true) }),
202
+ Schema.Struct({ report: ReportTerm }),
203
+ Schema.Struct({ fn: Schema.String }),
204
+ ]);
205
+ export type MeasureSource = (typeof MeasureSource)["Type"];
206
+
207
+ // A scalar objective's number: one source summed, a ratio of two sums
208
+ // (`scale × Σof / Σper`, 0 where `per` sums to 0), or — for a campaign with
209
+ // no perimeter, whose scope is its one sector — a command whose output is
210
+ // the number, read whole or through a `pattern` with a `value` group.
211
+ export const Measure = Schema.Union([
212
+ MeasureSource,
213
+ Schema.Struct({
214
+ ratio: Schema.Struct({ of: MeasureSource, per: MeasureSource, scale: Schema.Finite }),
215
+ }),
216
+ Schema.Struct({ command: Schema.String, pattern: Schema.optionalKey(Schema.String) }),
217
+ ]);
218
+ export type Measure = (typeof Measure)["Type"];
219
+
220
+ // Which way a scalar objective's number is better.
221
+ export const MeasureDirection = Schema.Literals(["down", "up"]);
222
+ export type MeasureDirection = (typeof MeasureDirection)["Type"];
223
+
192
224
  // An objective: a detector, a granularity, probes, and a ledger that only
193
225
  // shrinks on its own. Owned by one campaign; named by at most one phase (an
194
226
  // objective no phase names is in window in every phase). Exactly one of
195
- // `match` (a per-file detector, evaluated by both hosts) and `sector` (a
196
- // term over the sector's files, evaluated by the CLI) — or, for the
197
- // objectives an `endState` expands into, `endState` naming the family.
227
+ // `match` (a per-file detector, evaluated by both hosts), `sector` (a term
228
+ // over the sector's files, evaluated by the CLI) and `measure` (a number
229
+ // per sector, evaluated by the CLI and read off its ledger by the plugin) —
230
+ // or, for the objectives an `endState` expands into, `endState` naming the
231
+ // family.
198
232
  export const ObjectiveRule = Schema.Struct({
199
233
  // `campaign/<campaign>/<objective>`, the rule name a violation carries.
200
234
  name: Schema.String,
@@ -203,9 +237,17 @@ export const ObjectiveRule = Schema.Struct({
203
237
  // The `how`: what a reader at a holdout does about it.
204
238
  message: Schema.String,
205
239
  why: Schema.optionalKey(Schema.String),
206
- holdout: Holdout,
240
+ // Absent for a scalar objective, which holds nothing out.
241
+ holdout: Schema.optionalKey(Holdout),
207
242
  match: Schema.optionalKey(Detector),
208
243
  sector: Schema.optionalKey(SectorTerm),
244
+ // A scalar objective: a number per sector with a direction, held to the
245
+ // value its ledger last recorded within `tolerance`, and met — for the
246
+ // phase that names it — at `target`.
247
+ measure: Schema.optionalKey(Measure),
248
+ direction: Schema.optionalKey(MeasureDirection),
249
+ tolerance: Schema.optionalKey(Schema.Finite),
250
+ target: Schema.optionalKey(Schema.Finite),
209
251
  endState: Schema.optionalKey(
210
252
  Schema.Struct({
211
253
  phase: Schema.String,