@goodbones/campaigns 0.1.1-beta.1 → 0.1.1-beta.3

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 (55) 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/measure-command.d.ts +3 -0
  12. package/build/dts/host/measure-command.d.ts.map +1 -0
  13. package/build/dts/index.d.ts +5 -4
  14. package/build/dts/index.d.ts.map +1 -1
  15. package/build/dts/load/extension.d.ts +2 -1
  16. package/build/dts/load/extension.d.ts.map +1 -1
  17. package/build/dts/manifest/lower.d.ts.map +1 -1
  18. package/build/dts/manifest/spec.d.ts +209 -3
  19. package/build/dts/manifest/spec.d.ts.map +1 -1
  20. package/build/dts/ports/campaign-measure.d.ts +3 -0
  21. package/build/dts/ports/campaign-measure.d.ts.map +1 -0
  22. package/build/esm/core/campaign-state.js +35 -2
  23. package/build/esm/core/campaign-state.js.map +1 -1
  24. package/build/esm/core/campaigns.js +177 -2
  25. package/build/esm/core/campaigns.js.map +1 -1
  26. package/build/esm/core/ledger.js +215 -0
  27. package/build/esm/core/ledger.js.map +1 -1
  28. package/build/esm/domain/config.js +40 -4
  29. package/build/esm/domain/config.js.map +1 -1
  30. package/build/esm/host/campaigns.js +421 -28
  31. package/build/esm/host/campaigns.js.map +1 -1
  32. package/build/esm/host/measure-command.js +35 -0
  33. package/build/esm/host/measure-command.js.map +1 -0
  34. package/build/esm/index.js +2 -2
  35. package/build/esm/index.js.map +1 -1
  36. package/build/esm/load/extension.js +57 -13
  37. package/build/esm/load/extension.js.map +1 -1
  38. package/build/esm/manifest/lower.js +68 -1
  39. package/build/esm/manifest/lower.js.map +1 -1
  40. package/build/esm/manifest/spec.js +85 -5
  41. package/build/esm/manifest/spec.js.map +1 -1
  42. package/build/esm/ports/campaign-measure.js +2 -0
  43. package/build/esm/ports/campaign-measure.js.map +1 -0
  44. package/package.json +3 -3
  45. package/src/core/campaign-state.ts +49 -2
  46. package/src/core/campaigns.ts +232 -3
  47. package/src/core/ledger.ts +264 -2
  48. package/src/domain/config.ts +46 -4
  49. package/src/host/campaigns.ts +571 -33
  50. package/src/host/measure-command.ts +36 -0
  51. package/src/index.ts +31 -0
  52. package/src/load/extension.ts +70 -6
  53. package/src/manifest/lower.ts +80 -1
  54. package/src/manifest/spec.ts +100 -5
  55. package/src/ports/campaign-measure.ts +11 -0
@@ -1,16 +1,17 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import * as path from "node:path";
4
- import { compileExportRules, compileImportRules, compileMemberRules, compileStructure, compileSurfaceRules, evaluateMemberSite, evaluateResolvedEdge, evaluateSelectedBindings, evaluateStructure, evaluateSurface, exportRulesSelecting, globToRegExp, listSourceFiles, listWorkspaceProjects, memberRulesSelecting, rulesSelecting, surfaceRulesSelecting, } from "@goodbones/core";
4
+ import { breaches, compileExportRules, compileImportRules, compileMemberRules, compileStructure, compileSurfaceRules, evaluateMemberSite, evaluateResolvedEdge, evaluateSelectedBindings, evaluateStructure, evaluateSurface, exportRulesSelecting, globToRegExp, listSourceFiles, listWorkspaceProjects, memberRulesSelecting, rulesSelecting, surfaceRulesSelecting, } from "@goodbones/core";
5
5
  import * as Result from "effect/Result";
6
6
  import { evaluateCampaign, hitsInWindow, towardNextOf, } from "../core/campaign-state.js";
7
- import { detectorOf, needsSyntax, } from "../core/campaigns.js";
8
- import { attestedRecord, clearedSector, concededSector, EMPTY_LEDGER, EMPTY_SECTOR_RECORD, isComplete, isStalled, lastClearedOf, ledgerArithmeticHolds, ledgerPathOf, notedRecord, planDiffOf, planOf, planPathOf, progressOf, reachedRecord, rebaselinedSector, reconcileSector, sectorArithmeticHolds, sectorClockOf, sectorRecordPathOf, serializeLedger, serializePlanRecord, serializeSectorRecord, } from "../core/ledger.js";
7
+ import { detectorOf, distanceToTarget, measureDetectorsOf, needsSyntax, } from "../core/campaigns.js";
8
+ import { attestedRecord, clearedMeasure, clearedSector, concededMeasure, concededSector, EMPTY_LEDGER, EMPTY_MEASURE_LEDGER, EMPTY_SECTOR_RECORD, isComplete, isStalled, lastClearedOf, lastImprovedOf, ledgerArithmeticHolds, ledgerPathOf, measureLedgerArithmeticHolds, measureProgressOf, measureStandingOf, notedRecord, planDiffOf, planOf, planPathOf, progressOf, reachedRecord, rebaselinedSector, reconcileSector, recordedOf, sectorArithmeticHolds, sectorClockOf, sectorRecordPathOf, serializeLedger, serializeMeasureLedger, serializePlanRecord, serializeSectorRecord, } from "../core/ledger.js";
9
9
  import { compareResidue, isDefinedPhase, isOpenPhase, onTouchOf, worsened, } from "../core/phases.js";
10
10
  import { LEGACY_SECTOR, parseSectorMarker } from "../core/sectors.js";
11
11
  import { campaignsOf, ledgerKeyOf } from "../load/extension.js";
12
12
  import { lowerEndState } from "../manifest/lower.js";
13
13
  import { commitOf, commitsTouching, distanceToHunks, gitEnv, materializeTree, readDiff, textAt, } from "./diff.js";
14
+ import { commandValues } from "./measure-command.js";
14
15
  // The files a campaign sees: those in its scope with an extension a pack
15
16
  // walks, or one the campaign's scope widens the walk to.
16
17
  const filesFor = (policy, rule, files) => {
@@ -123,11 +124,12 @@ export const evaluateCampaigns = (policy, roots, files, readers = readersOf(poli
123
124
  const projects = campaignsOf(policy).campaignRules.some((rule) => rule.perimeter?.kind === "nx")
124
125
  ? listWorkspaceProjects(policy.repoRoot, roots)
125
126
  : [];
127
+ const commandValueOf = commandValues(policy.repoRoot);
126
128
  return campaignsOf(policy).campaignRules.map((rule) => {
127
129
  const detectors = [
128
130
  ...rule.objectives.flatMap((one) => {
129
131
  const detect = detectorOf(one);
130
- return detect === null ? [] : [detect];
132
+ return [...(detect === null ? [] : [detect]), ...measureDetectorsOf(one)];
131
133
  }),
132
134
  ...(rule.perimeter?.kind === "match" ? [rule.perimeter.detect] : []),
133
135
  ];
@@ -152,6 +154,7 @@ export const evaluateCampaigns = (policy, roots, files, readers = readersOf(poli
152
154
  globToRegExp,
153
155
  projects,
154
156
  recordOf: (sector) => campaignsOf(policy).sectorRecords.get(ledgerKeyOf(rule.id, sector)),
157
+ commandValueOf,
155
158
  };
156
159
  if (!rule.objectives.some((one) => one.endState !== null))
157
160
  return evaluateCampaign(rule, input);
@@ -177,6 +180,9 @@ export const evaluateCampaigns = (policy, roots, files, readers = readersOf(poli
177
180
  });
178
181
  };
179
182
  const ledgerOf = (policy, rule, objective) => campaignsOf(policy).ledgers.get(ledgerKeyOf(rule.id, objective.id));
183
+ const measureLedgerOf = (policy, rule, objective) => campaignsOf(policy).measureLedgers.get(ledgerKeyOf(rule.id, objective.id));
184
+ // How a scalar reads in a line: its value, and against what.
185
+ const describeValue = (value) => Number.isNaN(value) ? "no number" : String(value);
180
186
  const recordOf = (policy, rule, sector) => campaignsOf(policy).sectorRecords.get(ledgerKeyOf(rule.id, sector));
181
187
  const phaseIdOf = (rule, phase) => rule.phases[phase]?.id ?? null;
182
188
  const entriesOf = (hits, objective, sector) => hits
@@ -189,7 +195,113 @@ export const campaignReportsOf = (policy, evaluations) => evaluations.map((evalu
189
195
  const { rule } = evaluation;
190
196
  const counted = hitsInWindow(evaluation);
191
197
  let missingLedger = false;
198
+ const unmeasured = [];
199
+ const scalarNew = [];
200
+ const scalarStale = [];
201
+ // A scalar objective, reconciled per sector against its ledger. A value
202
+ // worse than the record past the tolerance is growth `concede` records;
203
+ // better past it, progress `clear` records — each a line of the same
204
+ // `new` and `stale` lists a holdout lands in, so `check` fails on them
205
+ // for the same reasons and says the same next step.
206
+ const scalarReport = (objective) => {
207
+ const ledger = measureLedgerOf(policy, rule, objective);
208
+ const sectors = [];
209
+ let value = 0;
210
+ let recorded = 0;
211
+ let measuredAll = true;
212
+ let recordedAll = true;
213
+ for (const name of sectorNames(evaluation)) {
214
+ const state = evaluation.sectors.get(name);
215
+ if (state === undefined)
216
+ continue;
217
+ const measured = state.values[objective.id] ?? Number.NaN;
218
+ const own = ledger?.sectors[name];
219
+ if (!state.inWindow.some((one) => one.id === objective.id)) {
220
+ // Past the window: a record still open is closed by `clear`.
221
+ if (own?.closed === null) {
222
+ scalarStale.push({
223
+ objective: objective.id,
224
+ sector: name,
225
+ entry: `left the window at ${describeValue(measured)}`,
226
+ });
227
+ }
228
+ continue;
229
+ }
230
+ if (Number.isNaN(measured)) {
231
+ measuredAll = false;
232
+ unmeasured.push({ objective: objective.id, sector: name });
233
+ }
234
+ else
235
+ value += measured;
236
+ if (ledger === undefined || own === undefined || own.closed !== null) {
237
+ missingLedger = true;
238
+ recordedAll = false;
239
+ sectors.push({
240
+ sector: name,
241
+ count: 0,
242
+ new: [],
243
+ stale: [],
244
+ drifted: 0,
245
+ unrecorded: true,
246
+ arithmetic: true,
247
+ measure: {
248
+ value: Number.isNaN(measured) ? null : measured,
249
+ recorded: null,
250
+ standing: Number.isNaN(measured) ? "unmeasured" : "unrecorded",
251
+ },
252
+ });
253
+ continue;
254
+ }
255
+ recorded += own.recorded;
256
+ const standing = Number.isNaN(measured)
257
+ ? "unmeasured"
258
+ : measureStandingOf(ledger, name, measured, objective.tolerance);
259
+ const line = `measured ${describeValue(measured)}, recorded ${String(own.recorded)}`;
260
+ if (standing === "breached") {
261
+ scalarNew.push({ objective: objective.id, sector: name, entry: line });
262
+ }
263
+ if (standing === "surpassed") {
264
+ scalarStale.push({ objective: objective.id, sector: name, entry: line });
265
+ }
266
+ sectors.push({
267
+ sector: name,
268
+ count: 0,
269
+ new: standing === "breached" ? [line] : [],
270
+ stale: standing === "surpassed" ? [line] : [],
271
+ drifted: 0,
272
+ unrecorded: false,
273
+ arithmetic: measureLedgerArithmeticHolds(ledger),
274
+ measure: {
275
+ value: Number.isNaN(measured) ? null : measured,
276
+ recorded: own.recorded,
277
+ standing,
278
+ },
279
+ });
280
+ }
281
+ // Sectors the ledger records that the code no longer births: closed
282
+ // on the next `clear`, stale until then.
283
+ for (const [name, own] of Object.entries(ledger?.sectors ?? {})) {
284
+ if (evaluation.sectors.has(name) || own.closed !== null)
285
+ continue;
286
+ scalarStale.push({ objective: objective.id, sector: name, entry: "no longer a sector" });
287
+ }
288
+ return {
289
+ id: objective.id,
290
+ count: 0,
291
+ ledgered: ledger !== undefined,
292
+ sectors,
293
+ measure: {
294
+ direction: objective.direction,
295
+ value: measuredAll ? value : null,
296
+ recorded: recordedAll ? recorded : null,
297
+ target: objective.target,
298
+ tolerance: objective.tolerance,
299
+ },
300
+ };
301
+ };
192
302
  const objectives = rule.objectives.map((objective) => {
303
+ if (objective.measure !== null)
304
+ return scalarReport(objective);
193
305
  const ledger = ledgerOf(policy, rule, objective);
194
306
  const sectors = [];
195
307
  for (const name of sectorNames(evaluation)) {
@@ -271,6 +383,19 @@ export const campaignReportsOf = (policy, evaluations) => evaluations.map((evalu
271
383
  const ledger = ledgerOf(policy, rule, objective);
272
384
  return ledger === undefined ? [] : [ledger];
273
385
  });
386
+ const measureLedgers = rule.objectives.flatMap((objective) => {
387
+ const ledger = measureLedgerOf(policy, rule, objective);
388
+ return ledger === undefined ? [] : [{ objective, ledger }];
389
+ });
390
+ // A targeted scalar is met in every sector in window when its distance
391
+ // is 0 there; a standing one never holds a campaign open.
392
+ const scalarsMet = rule.objectives
393
+ .filter((objective) => objective.measure !== null && objective.target !== null)
394
+ .every((objective) => [...evaluation.sectors.values()].every((state) => !state.inWindow.some((one) => one.id === objective.id) ||
395
+ (state.counts[objective.id] ?? 0) === 0));
396
+ const scalarsOpen = measureLedgers.filter(({ objective }) => objective.target !== null &&
397
+ [...evaluation.sectors.values()].some((state) => state.inWindow.some((one) => one.id === objective.id) &&
398
+ (state.counts[objective.id] ?? 0) > 0));
274
399
  const sectorClock = [...evaluation.sectors.keys()]
275
400
  .map((name) => recordOf(policy, rule, name))
276
401
  .filter((one) => one !== undefined)
@@ -280,14 +405,23 @@ export const campaignReportsOf = (policy, evaluations) => evaluations.map((evalu
280
405
  return {
281
406
  id: rule.id,
282
407
  count,
283
- new: flat((one) => one.new),
284
- stale: flat((one) => one.stale),
408
+ new: [
409
+ ...flat((one) => (one.measure === undefined ? one.new : [])),
410
+ ...scalarNew,
411
+ ],
412
+ stale: [
413
+ ...flat((one) => (one.measure === undefined ? one.stale : [])),
414
+ ...scalarStale,
415
+ ],
285
416
  drifted: objectives.reduce((sum, one) => sum + one.sectors.reduce((inner, two) => inner + two.drifted, 0), 0),
286
417
  missingLedger,
287
- arithmetic: ledgers.every(ledgerArithmeticHolds),
288
- complete: count === 0 && ledgers.every(isComplete),
289
- stalled: ledgers.length > 0 &&
290
- ledgers.some((ledger) => isStalled(rule, ledger, policy.now, sectorClock)),
418
+ unmeasured,
419
+ arithmetic: ledgers.every(ledgerArithmeticHolds) &&
420
+ measureLedgers.every(({ ledger }) => measureLedgerArithmeticHolds(ledger)),
421
+ complete: count === 0 && ledgers.every(isComplete) && scalarsMet && unmeasured.length === 0,
422
+ stalled: (ledgers.length > 0 &&
423
+ ledgers.some((ledger) => isStalled(rule, ledger, policy.now, sectorClock))) ||
424
+ scalarsOpen.some(({ ledger }) => isMeasureStalled(rule, ledger, policy.now, sectorClock)),
291
425
  onComplete: rule.onComplete,
292
426
  objectives,
293
427
  sectors: [...evaluation.sectors.values()].map((state) => ({
@@ -301,6 +435,17 @@ export const campaignReportsOf = (policy, evaluations) => evaluations.map((evalu
301
435
  plan: planDiffOf(rule, campaignsOf(policy).plans.get(rule.id)),
302
436
  };
303
437
  });
438
+ // A scalar short of its target is stalled when its record has not improved,
439
+ // and no sector has been attested or noted, within the campaign's
440
+ // `staleAfter`.
441
+ const isMeasureStalled = (rule, ledger, now, sectorClock) => {
442
+ if (rule.staleAfter === null)
443
+ return false;
444
+ const last = [lastImprovedOf(ledger), sectorClock]
445
+ .filter((one) => one !== null)
446
+ .reduce((a, b) => (a > b ? a : b), ledger.created);
447
+ return now - Date.parse(last) > rule.staleAfter;
448
+ };
304
449
  // Which hits in window are carried by a ledger, exactly or by anchor.
305
450
  export const ledgeredFilter = (policy, evaluations) => {
306
451
  const carried = new Set();
@@ -330,6 +475,9 @@ export const campaignFailuresOf = (campaigns) => [
330
475
  ...campaigns
331
476
  .filter((one) => one.plan.unreceipted.length > 0)
332
477
  .map((one) => `campaign ${one.id}: a defined phase changed without a concession`),
478
+ ...campaigns
479
+ .filter((one) => one.unmeasured.length > 0)
480
+ .map((one) => `campaign ${one.id}: a scalar objective measured no number`),
333
481
  ...campaigns.filter((one) => one.stale.length > 0).map(() => "stale ledger entries"),
334
482
  ...campaigns.filter((one) => !one.arithmetic).map(() => "ledger arithmetic does not hold"),
335
483
  ...campaigns.filter((one) => one.missingLedger).map((one) => `campaign ${one.id} has no ledger`),
@@ -358,10 +506,16 @@ export const renderCampaignReports = (reports, hits) => reports.flatMap((campaig
358
506
  }
359
507
  if (campaign.missingLedger) {
360
508
  const unrecorded = campaign.objectives.flatMap((objective) => objective.sectors
361
- .filter((one) => one.unrecorded && (one.count > 0 || objective.ledgered))
362
- .map((one) => ` ${objective.id} · ${one.sector} (${count(one.count, "hit")})`));
509
+ .filter((one) => one.unrecorded &&
510
+ (one.count > 0 || objective.ledgered || one.measure !== undefined))
511
+ .map((one) => ` ${objective.id} · ${one.sector} (${one.measure === undefined
512
+ ? count(one.count, "hit")
513
+ : `measures ${one.measure.value === null ? "no number" : String(one.measure.value)}`})`));
363
514
  say("", `campaign ${campaign.id}: ${count(unrecorded.length, "sector")} in an objective's window that no ledger has seen. Record them before they count as growth:`, ...unrecorded, "", ` architecture objectives clear ${campaign.id}`);
364
515
  }
516
+ if (campaign.unmeasured.length > 0) {
517
+ say("", `campaign ${campaign.id}: ${count(campaign.unmeasured.length, "sector")} where a scalar objective measured no number — a function that answered something other than a number of zero or more, or a command that did not run or printed none:`, ...campaign.unmeasured.map((one) => ` ${one.objective} · ${one.sector}`));
518
+ }
365
519
  if (campaign.new.length > 0 && !campaign.missingLedger) {
366
520
  say("", `campaign ${campaign.id}: ${count(campaign.new.length, "new hit")} the ledger does not carry. Fix them, or record why the count may rise:`, ...campaign.new.flatMap((one) => {
367
521
  const hit = hits.find((two) => two.objective === one.objective &&
@@ -377,7 +531,7 @@ export const renderCampaignReports = (reports, hits) => reports.flatMap((campaig
377
531
  say("", `campaign ${campaign.id}: ${count(campaign.stale.length, "ledger entry", "ledger entries")} no longer fire, or fire past their window. The code was fixed, or the sector moved on; clear them:`, ...campaign.stale.map((one) => at(one.objective, one.sector, one.entry)), "", ` architecture objectives clear ${campaign.id}`);
378
532
  }
379
533
  if (!campaign.arithmetic) {
380
- say("", `campaign ${campaign.id}: a ledger does not add up (holdouts ≠ initial + conceded − cleared − closed). A holdout was added by hand; remove it, or record it with \`objectives concede\`.`);
534
+ say("", `campaign ${campaign.id}: a ledger does not add up (holdouts ≠ initial + conceded − cleared − closed; for a scalar, recorded ≠ initial + risen − improved). The ledger was edited by hand; restore it, or record the change with \`objectives concede\`.`);
381
535
  }
382
536
  const complete = campaign.complete && !campaign.missingLedger;
383
537
  if (complete && campaign.onComplete === "remove") {
@@ -405,10 +559,39 @@ export const snapshotCampaignsOf = (policy, evaluations) => campaignReportsOf(po
405
559
  throw new Error("report without evaluation");
406
560
  const { rule } = evaluation;
407
561
  const ledgers = rule.objectives.map((objective) => ledgerOf(policy, rule, objective));
562
+ const scalar = new Set(rule.objectives.filter((one) => one.measure !== null).map((one) => one.id));
408
563
  const objectives = rule.objectives.map((objective, j) => {
409
564
  const ledger = ledgers[j];
410
565
  const own = report.objectives[j];
411
566
  const phase = rule.phases.find((one) => one.objectives.includes(objective.id))?.id ?? null;
567
+ if (objective.measure !== null) {
568
+ // A scalar: the holdout counts are all 0, and the number is under
569
+ // `measure`. Met when every sector in window is at its target.
570
+ const measured = measureLedgerOf(policy, rule, objective);
571
+ const met = objective.target !== null &&
572
+ (own?.sectors ?? []).every((one) => (evaluation.sectors.get(one.sector)?.counts[objective.id] ?? 1) === 0);
573
+ return {
574
+ id: objective.id,
575
+ phase,
576
+ initial: 0,
577
+ allowed: 0,
578
+ count: 0,
579
+ cleared: 0,
580
+ closed: 0,
581
+ progress: measured === undefined ? 0 : measureProgressOf(measured, objective.target),
582
+ lastCleared: measured === undefined ? null : lastImprovedOf(measured),
583
+ concessions: measured?.concessions.length ?? 0,
584
+ complete: met,
585
+ ledgered: measured !== undefined,
586
+ measure: {
587
+ direction: objective.direction,
588
+ value: own?.measure?.value ?? 0,
589
+ recorded: own?.measure?.recorded ?? (measured === undefined ? 0 : recordedOf(measured)),
590
+ target: objective.target,
591
+ tolerance: objective.tolerance,
592
+ },
593
+ };
594
+ }
412
595
  if (ledger === undefined) {
413
596
  return {
414
597
  id: objective.id,
@@ -481,13 +664,19 @@ export const snapshotCampaignsOf = (policy, evaluations) => campaignReportsOf(po
481
664
  }),
482
665
  legacy: {
483
666
  files: evaluation.index.legacy.length,
484
- holdouts: legacy === undefined ? 0 : Object.values(legacy.residue).reduce((a, b) => a + b, 0),
667
+ holdouts: legacy === undefined
668
+ ? 0
669
+ : Object.entries(legacy.residue)
670
+ .filter(([id]) => !scalar.has(id))
671
+ .reduce((a, [, n]) => a + n, 0),
485
672
  },
486
673
  plan: report.plan,
487
674
  stalled: report.stalled,
488
675
  complete: report.complete,
489
676
  onComplete: rule.onComplete,
490
- ledgered: ledgers.every((one) => one !== undefined),
677
+ ledgered: rule.objectives.every((objective, j) => objective.measure === null
678
+ ? ledgers[j] !== undefined
679
+ : measureLedgerOf(policy, rule, objective) !== undefined),
491
680
  };
492
681
  });
493
682
  const percent = (fraction) => `${String(Math.round(fraction * 100))}%`;
@@ -515,10 +704,16 @@ export const renderCampaignRows = (campaigns) => {
515
704
  .join(" → ")}` +
516
705
  (one.legacy.files > 0 ? ` · legacy ${count(one.legacy.files, "file")}` : ""),
517
706
  ]),
518
- ...one.objectives.map((objective) => ` ${objective.id.padEnd(width)} ${percent(objective.progress).padStart(4)} ${String(objective.count).padStart(5)} left` +
519
- ` ${String(objective.cleared)} cleared ${String(objective.allowed)} conceded` +
520
- (objective.closed > 0 ? ` ${String(objective.closed)} closed` : "") +
521
- (objective.ledgered ? "" : " no ledger")),
707
+ ...one.objectives.map((objective) => objective.measure !== undefined
708
+ ? ` ${objective.id.padEnd(width)} ${percent(objective.progress).padStart(4)} measures ${String(objective.measure.value)}, held to ${String(objective.measure.recorded)}` +
709
+ (objective.measure.target === null
710
+ ? ""
711
+ : `, target ${String(objective.measure.target)}`) +
712
+ (objective.ledgered ? "" : " no ledger")
713
+ : ` ${objective.id.padEnd(width)} ${percent(objective.progress).padStart(4)} ${String(objective.count).padStart(5)} left` +
714
+ ` ${String(objective.cleared)} cleared ${String(objective.allowed)} conceded` +
715
+ (objective.closed > 0 ? ` ${String(objective.closed)} closed` : "") +
716
+ (objective.ledgered ? "" : " no ledger")),
522
717
  ];
523
718
  });
524
719
  };
@@ -550,6 +745,80 @@ export const authorOf = (given) => {
550
745
  const fromEnvironment = process.env.GIT_AUTHOR_EMAIL;
551
746
  return fromEnvironment === undefined || fromEnvironment === "" ? null : fromEnvironment;
552
747
  };
748
+ // `clear` for a scalar objective: a sector entering the window is recorded
749
+ // at its value; one inside it whose value improved past the tolerance is
750
+ // held to that value from now; one past it, or no longer born, is closed;
751
+ // and a receipted phase change re-baselines the sectors in window to what
752
+ // they measure, with a concession. A rise is left where it is.
753
+ const clearMeasure = (policy, evaluation, objective, receipted, by) => {
754
+ const { rule } = evaluation;
755
+ const existing = measureLedgerOf(policy, rule, objective);
756
+ const before = existing ?? EMPTY_MEASURE_LEDGER(rule.id, objective.id, objective.direction, policy.now);
757
+ let ledger = before;
758
+ const entered = [];
759
+ const rebaselined = [];
760
+ const improved = [];
761
+ let closed = 0;
762
+ for (const [name, state] of evaluation.sectors) {
763
+ const value = state.values[objective.id] ?? Number.NaN;
764
+ const own = ledger.sectors[name];
765
+ if (!state.inWindow.some((one) => one.id === objective.id)) {
766
+ const next = clearedMeasure(ledger, name, value, objective.tolerance, policy.now, "outside");
767
+ if (next !== ledger)
768
+ closed += 1;
769
+ ledger = next;
770
+ continue;
771
+ }
772
+ if (own === undefined || own.closed !== null) {
773
+ const next = clearedMeasure(ledger, name, value, objective.tolerance, policy.now, "inside", by);
774
+ if (next !== ledger)
775
+ entered.push(name);
776
+ ledger = next;
777
+ continue;
778
+ }
779
+ if (receipted !== null) {
780
+ const next = concededMeasure(ledger, name, value, {
781
+ at: policy.now,
782
+ by,
783
+ reason: `phase ${receipted.id} changed: ${receipted.concessions.at(-1)?.reason ?? ""}`,
784
+ });
785
+ if (next !== ledger)
786
+ rebaselined.push(name);
787
+ ledger = next;
788
+ continue;
789
+ }
790
+ const next = clearedMeasure(ledger, name, value, objective.tolerance, policy.now, "inside");
791
+ if (next !== ledger) {
792
+ improved.push({
793
+ sector: name,
794
+ from: own.recorded,
795
+ to: next.sectors[name]?.recorded ?? own.recorded,
796
+ });
797
+ }
798
+ ledger = next;
799
+ }
800
+ // Sectors the code no longer births are past every window.
801
+ for (const [name, own] of Object.entries(ledger.sectors)) {
802
+ if (evaluation.sectors.has(name) || own.closed !== null)
803
+ continue;
804
+ ledger = clearedMeasure(ledger, name, own.recorded, objective.tolerance, policy.now, "outside");
805
+ closed += 1;
806
+ }
807
+ if (ledger !== before || existing === undefined) {
808
+ writeJson(policy.repoRoot, ledgerPathOf(campaignsOf(policy).ledgerDir, rule.id, objective.id), serializeMeasureLedger(ledger));
809
+ }
810
+ return {
811
+ campaign: rule.id,
812
+ objective: objective.id,
813
+ cleared: 0,
814
+ rewritten: 0,
815
+ closed,
816
+ entered,
817
+ rebaselined,
818
+ left: 0,
819
+ measure: { improved, recorded: recordedOf(ledger) },
820
+ };
821
+ };
553
822
  // `clear`: the ledger reconciled with the code wherever that is not a
554
823
  // regression. Stale holdouts leave and drifted ones are rewritten; a sector
555
824
  // newly in an objective's window is recorded with its initial; a sector
@@ -565,15 +834,19 @@ export const clear = (policy, evaluation, only, by) => {
565
834
  for (const objective of rule.objectives) {
566
835
  if (only !== null && objective.id !== only)
567
836
  continue;
568
- const before = ledgerOf(policy, rule, objective) ?? EMPTY_LEDGER(rule.id, objective.id, policy.now);
569
- let ledger = before;
570
- const entered = [];
571
- const rebaselined = [];
572
837
  const phase = rule.phases.find((one) => one.objectives.includes(objective.id));
573
838
  // The phase naming the objective, when it changed with a receipt.
574
839
  const receipted = phase !== undefined && plan.changed.includes(phase.id) && !plan.unreceipted.includes(phase.id)
575
840
  ? phase
576
841
  : null;
842
+ if (objective.measure !== null) {
843
+ outcomes.push(clearMeasure(policy, evaluation, objective, receipted, by));
844
+ continue;
845
+ }
846
+ const before = ledgerOf(policy, rule, objective) ?? EMPTY_LEDGER(rule.id, objective.id, policy.now);
847
+ let ledger = before;
848
+ const entered = [];
849
+ const rebaselined = [];
577
850
  let rewritten = 0;
578
851
  for (const [name, state] of evaluation.sectors) {
579
852
  const inWindow = state.inWindow.some((one) => one.id === objective.id);
@@ -645,6 +918,12 @@ export const concede = (policy, evaluation, objectiveId, chosen, sector, record)
645
918
  const objective = rule.objectives.find((one) => one.id === objectiveId);
646
919
  if (objective === undefined)
647
920
  return Result.fail(`no objective of ${rule.id} is named "${objectiveId}"`);
921
+ if (objective.measure !== null) {
922
+ if (chosen !== null) {
923
+ return Result.fail(`${rule.id}/${objectiveId} is a scalar objective: it has no holdouts to choose among. Narrow with --sector.`);
924
+ }
925
+ return concedeMeasure(policy, evaluation, objective, sector, record);
926
+ }
648
927
  let ledger = ledgerOf(policy, rule, objective);
649
928
  if (ledger === undefined) {
650
929
  return Result.fail(`objective ${rule.id}/${objectiveId} has no ledger yet; run \`objectives clear ${rule.id}\` first.`);
@@ -684,6 +963,41 @@ export const concede = (policy, evaluation, objectiveId, chosen, sector, record)
684
963
  left: unrecorded.filter((one) => !wanted.includes(one)),
685
964
  });
686
965
  };
966
+ // `concede` for a scalar objective: each sector in window whose value is
967
+ // worse than its record past the tolerance is held to its value from now,
968
+ // with a concession naming the reason and the author. A sector the ledger
969
+ // has not recorded is `clear`'s to enter first.
970
+ const concedeMeasure = (policy, evaluation, objective, sector, record) => {
971
+ const { rule } = evaluation;
972
+ let ledger = measureLedgerOf(policy, rule, objective);
973
+ if (ledger === undefined) {
974
+ return Result.fail(`objective ${rule.id}/${objective.id} has no ledger yet; run \`objectives clear ${rule.id}\` first.`);
975
+ }
976
+ const conceded = [];
977
+ const left = [];
978
+ for (const [name, state] of evaluation.sectors) {
979
+ if (!state.inWindow.some((one) => one.id === objective.id))
980
+ continue;
981
+ const value = state.values[objective.id] ?? Number.NaN;
982
+ const standing = Number.isNaN(value)
983
+ ? "unmeasured"
984
+ : measureStandingOf(ledger, name, value, objective.tolerance);
985
+ if (standing !== "breached")
986
+ continue;
987
+ const from = ledger.sectors[name]?.recorded ?? value;
988
+ const entry = `${String(from)} → ${String(value)}`;
989
+ if (sector !== null && name !== sector) {
990
+ left.push({ sector: name, entry });
991
+ continue;
992
+ }
993
+ ledger = concededMeasure(ledger, name, value, record);
994
+ conceded.push({ sector: name, entry });
995
+ }
996
+ if (conceded.length > 0) {
997
+ writeJson(policy.repoRoot, ledgerPathOf(campaignsOf(policy).ledgerDir, rule.id, objective.id), serializeMeasureLedger(ledger));
998
+ }
999
+ return Result.succeed({ objective: objective.id, conceded, left });
1000
+ };
687
1001
  // `attest`: a step no detector sees is recorded done for a sector — with a
688
1002
  // reason and evidence — and only while the sector stands at that phase, so
689
1003
  // it cannot be recorded ahead and passed through on arrival.
@@ -738,6 +1052,11 @@ export const baseSideOf = (evaluations) => evaluations.map((evaluation) => ({
738
1052
  objective: hit.objective,
739
1053
  entry: hit.entry,
740
1054
  })),
1055
+ // `NaN` does not survive the JSON cache; `null` is "no number".
1056
+ values: Object.fromEntries([...evaluation.sectors.values()].map((state) => [
1057
+ state.name,
1058
+ Object.fromEntries(Object.entries(state.values).map(([id, value]) => [id, Number.isNaN(value) ? null : value])),
1059
+ ])),
741
1060
  }));
742
1061
  // The nudge for one diff. The "before" side is the ledger, or the base tree
743
1062
  // evaluated whole in the exact mode; the "after" side is the tree now.
@@ -785,8 +1104,38 @@ export const nudgeOf = (policy, evaluations, diff, base, hotfix, by) => {
785
1104
  const open = phase !== undefined && isOpenPhase(phase);
786
1105
  const onTouch = onTouchOf(rule, state.phase);
787
1106
  // Before: the ledger's count per objective in window, or the base tree's.
1107
+ // A scalar's is its distance to target from the value before: the
1108
+ // base tree's, or the ledger's record.
788
1109
  const before = {};
1110
+ const measures = [];
789
1111
  for (const objective of state.inWindow) {
1112
+ if (objective.measure !== null) {
1113
+ const recorded = measureLedgerOf(policy, rule, objective)?.sectors[name];
1114
+ const baseValue = baseEvaluation?.values === undefined
1115
+ ? undefined
1116
+ : baseEvaluation.values[name]?.[objective.id];
1117
+ const was = baseValue !== undefined
1118
+ ? baseValue
1119
+ : recorded === undefined || recorded.closed !== null
1120
+ ? null
1121
+ : recorded.recorded;
1122
+ const value = state.values[objective.id] ?? Number.NaN;
1123
+ const now_ = Number.isNaN(value) ? null : value;
1124
+ before[objective.id] =
1125
+ was === null ? (state.counts[objective.id] ?? 0) : distanceToTarget(objective, was);
1126
+ measures.push({
1127
+ objective: objective.id,
1128
+ direction: objective.direction,
1129
+ before: was,
1130
+ after: now_,
1131
+ target: objective.target,
1132
+ tolerance: objective.tolerance,
1133
+ back: was !== null &&
1134
+ (now_ === null ||
1135
+ breaches({ direction: objective.direction, limit: was, tolerance: objective.tolerance }, now_)),
1136
+ });
1137
+ continue;
1138
+ }
790
1139
  if (baseEvaluation !== null) {
791
1140
  before[objective.id] = baseEvaluation.sectors[name]?.[objective.id] ?? 0;
792
1141
  }
@@ -869,9 +1218,19 @@ export const nudgeOf = (policy, evaluations, diff, base, hotfix, by) => {
869
1218
  if (state_.stale.length > 0)
870
1219
  editsHoldout = true;
871
1220
  }
872
- const back = worsened(before, after);
873
- const totalBefore = Object.values(before).reduce((a, b) => a + b, 0);
874
- const totalAfter = Object.values(after).reduce((a, b) => a + b, 0);
1221
+ // A scalar goes back by its tolerance, not by any rise of its
1222
+ // distance; the holdout dimensions go back by any rise at all. Only
1223
+ // the holdouts are paid down: a scalar has none to touch.
1224
+ const scalar = new Set(measures.map((one) => one.objective));
1225
+ const back = [
1226
+ ...worsened(before, after).filter((id) => !scalar.has(id)),
1227
+ ...measures.filter((one) => one.back).map((one) => one.objective),
1228
+ ].sort();
1229
+ const holdoutTotal = (vector) => Object.entries(vector)
1230
+ .filter(([id]) => !scalar.has(id))
1231
+ .reduce((sum, [, n]) => sum + n, 0);
1232
+ const totalBefore = holdoutTotal(before);
1233
+ const totalAfter = holdoutTotal(after);
875
1234
  let ask;
876
1235
  let verdict = "ok";
877
1236
  if (open)
@@ -893,6 +1252,16 @@ export const nudgeOf = (policy, evaluations, diff, base, hotfix, by) => {
893
1252
  if (verdict !== "ok" && hotfix !== null && by !== null) {
894
1253
  // The escape: the growth is conceded, with a reason naming the hotfix.
895
1254
  for (const objective of state.inWindow) {
1255
+ if (objective.measure !== null) {
1256
+ if (measures.some((one) => one.objective === objective.id && one.back)) {
1257
+ concedeMeasure(policy, evaluation, objective, name, {
1258
+ at: policy.now,
1259
+ by,
1260
+ reason: `hotfix: ${hotfix}`,
1261
+ });
1262
+ }
1263
+ continue;
1264
+ }
896
1265
  const ledger = ledgerOf(policy, rule, objective);
897
1266
  if (ledger === undefined)
898
1267
  continue;
@@ -935,6 +1304,7 @@ export const nudgeOf = (policy, evaluations, diff, base, hotfix, by) => {
935
1304
  holdouts: { total: own.length, cap: HOLDOUT_CAP, shown },
936
1305
  added: added.sort(),
937
1306
  removed: removed.sort(),
1307
+ measures,
938
1308
  belongsInSector: name === LEGACY_SECTOR ? belongs.sort() : [],
939
1309
  });
940
1310
  }
@@ -1000,6 +1370,7 @@ export const renderNudge = (nudge, now) => {
1000
1370
  ? "its objectives"
1001
1371
  : `phase ${one.phase.id ?? "done"} (${String(one.phase.index + 1)} of ${String(one.phase.of)}${one.phase.open ? ", open" : ""})`;
1002
1372
  const quiet = one.holdouts.total === 0 &&
1373
+ one.measures.every((measure) => measure.before === measure.after) &&
1003
1374
  one.direction === "neutral" &&
1004
1375
  !one.phase.open &&
1005
1376
  one.verdict === "ok" &&
@@ -1030,6 +1401,17 @@ export const renderNudge = (nudge, now) => {
1030
1401
  else if (one.direction !== "neutral") {
1031
1402
  say(` this diff: ${residueOf(one.residue.before)} → ${residueOf(one.residue.after)} ${one.direction}`);
1032
1403
  }
1404
+ for (const measure of one.measures) {
1405
+ if (measure.before === measure.after && !measure.back)
1406
+ continue;
1407
+ const bounds = [
1408
+ ...(measure.target === null ? [] : [`target ${String(measure.target)}`]),
1409
+ ...(measure.tolerance === 0 ? [] : [`tolerance ${String(measure.tolerance)}`]),
1410
+ ];
1411
+ say(` ${measure.objective}: ${measure.before === null ? "unrecorded" : String(measure.before)} → ${measure.after === null ? "no number" : String(measure.after)}` +
1412
+ (bounds.length === 0 ? "" : ` (${bounds.join(", ")})`) +
1413
+ (measure.back ? " back" : ""));
1414
+ }
1033
1415
  }
1034
1416
  for (const file of one.belongsInSector)
1035
1417
  say(` ${file} landed in the legacy inside the scope: this belongs in a sector`);
@@ -1054,9 +1436,14 @@ export const historyOf = (policy, rule, since, manifestPaths) => {
1054
1436
  continue;
1055
1437
  try {
1056
1438
  const raw = JSON.parse(text);
1439
+ // A scalar's column is what its open sectors were held to.
1057
1440
  counts[objective.id] =
1058
- raw.entries?.length ??
1059
- Object.values(raw.sectors ?? {}).reduce((sum, one) => sum + (one.holdouts?.length ?? 0), 0);
1441
+ raw.kind === "measure"
1442
+ ? Math.round(Object.values(raw.sectors ?? {})
1443
+ .filter((one) => one.closed === null || one.closed === undefined)
1444
+ .reduce((sum, one) => sum + (one.recorded ?? 0), 0) * 1e6) / 1e6
1445
+ : (raw.entries?.length ??
1446
+ Object.values(raw.sectors ?? {}).reduce((sum, one) => sum + (one.holdouts?.length ?? 0), 0));
1060
1447
  }
1061
1448
  catch {
1062
1449
  // an unreadable ledger at that commit contributes nothing
@@ -1106,6 +1493,12 @@ export const explainCampaignLines = (policy, evaluation, file) => {
1106
1493
  return [
1107
1494
  ` ${rule.name}: sector ${sector}${at}${record?.reached === undefined || record.reached === null ? "" : `, reached ${record.reached}`}`,
1108
1495
  ` in window: ${state.inWindow.length === 0 ? "(nothing)" : state.inWindow.map((one) => `${one.id}${firing.has(one.id) ? " ✗" : ""}`).join(", ")}`,
1496
+ ...state.inWindow
1497
+ .filter((one) => one.measure !== null)
1498
+ .map((one) => {
1499
+ const recorded = measureLedgerOf(policy, rule, one)?.sectors[sector];
1500
+ return ` ${one.id}: the sector measures ${describeValue(state.values[one.id] ?? Number.NaN)}${recorded === undefined || recorded.closed !== null ? ", unrecorded" : `, held to ${String(recorded.recorded)}`}${one.target === null ? "" : `, target ${String(one.target)}`}`;
1501
+ }),
1109
1502
  ...(own.length === 0
1110
1503
  ? []
1111
1504
  : [