@formicoidea/labre-core 0.37.0 → 0.38.1

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 (53) hide show
  1. package/dist/_pkgs/affine-block-frame/frame-manager.d.ts +31 -0
  2. package/dist/_pkgs/affine-block-frame/frame-manager.js +62 -2
  3. package/dist/_pkgs/affine-block-frame/frame-tool.js +6 -2
  4. package/dist/_pkgs/affine-block-surface/extensions/audit.js +12 -34
  5. package/dist/_pkgs/affine-block-surface/extensions/reading-toolbar.js +12 -12
  6. package/dist/_pkgs/affine-block-surface/extensions/reading.js +10 -28
  7. package/dist/_pkgs/affine-block-surface/extensions/validation.d.ts +321 -16
  8. package/dist/_pkgs/affine-block-surface/extensions/validation.js +945 -62
  9. package/dist/_pkgs/affine-block-surface/framework-background/facts.d.ts +24 -0
  10. package/dist/_pkgs/affine-block-surface/framework-background/facts.js +31 -0
  11. package/dist/_pkgs/affine-block-surface/framework-background/index.d.ts +1 -0
  12. package/dist/_pkgs/affine-block-surface/framework-background/index.js +1 -0
  13. package/dist/_pkgs/affine-block-surface/framework-background/stacking.d.ts +131 -0
  14. package/dist/_pkgs/affine-block-surface/framework-background/stacking.js +184 -0
  15. package/dist/_pkgs/affine-block-surface/utils/sort.d.ts +15 -1
  16. package/dist/_pkgs/affine-block-surface/utils/sort.js +30 -31
  17. package/dist/_pkgs/affine-block-surface/view.js +5 -0
  18. package/dist/_pkgs/affine-components/highlight-dropdown-menu/highlight-duotone-icon.js +8 -8
  19. package/dist/_pkgs/affine-components/highlight-dropdown-menu/text-duotone-icon.js +11 -11
  20. package/dist/_pkgs/affine-components/icons/text.js +426 -426
  21. package/dist/_pkgs/affine-gfx-group/element-renderer/utils.js +3 -1
  22. package/dist/_pkgs/affine-gfx-template/index.d.ts +1 -0
  23. package/dist/_pkgs/affine-gfx-template/index.js +1 -0
  24. package/dist/_pkgs/affine-gfx-template/make-snapshot.d.ts +16 -2
  25. package/dist/_pkgs/affine-gfx-template/make-snapshot.js +43 -4
  26. package/dist/_pkgs/affine-gfx-template/services/template-middlewares.js +1 -0
  27. package/dist/_pkgs/affine-gfx-template/services/template.d.ts +6 -0
  28. package/dist/_pkgs/affine-gfx-template/services/template.js +6 -0
  29. package/dist/_pkgs/affine-gfx-template/snapshot-from-action.d.ts +20 -0
  30. package/dist/_pkgs/affine-gfx-template/snapshot-from-action.js +205 -0
  31. package/dist/_pkgs/affine-gfx-template/toolbar/template-panel.js +4 -0
  32. package/dist/_pkgs/affine-gfx-template/toolbar/template-type.d.ts +13 -0
  33. package/dist/_pkgs/affine-inline-link/link-node/configs/toolbar.d.ts +11 -17
  34. package/dist/_pkgs/affine-inline-link/link-node/configs/toolbar.js +55 -14
  35. package/dist/_pkgs/affine-model/elements/cynefin/cynefin.d.ts +9 -20
  36. package/dist/_pkgs/affine-model/elements/cynefin/cynefin.js +7 -28
  37. package/dist/_pkgs/affine-model/elements/edgy/board.d.ts +9 -21
  38. package/dist/_pkgs/affine-model/elements/edgy/board.js +8 -29
  39. package/dist/_pkgs/affine-model/elements/edgy/facets.d.ts +14 -28
  40. package/dist/_pkgs/affine-model/elements/edgy/facets.js +12 -36
  41. package/dist/_pkgs/affine-model/elements/estuarine/estuarine.d.ts +9 -19
  42. package/dist/_pkgs/affine-model/elements/estuarine/estuarine.js +7 -27
  43. package/dist/_pkgs/affine-shared/services/link-preview-service/link-preview-service.d.ts +28 -0
  44. package/dist/_pkgs/affine-shared/services/link-preview-service/link-preview-service.js +20 -3
  45. package/dist/_pkgs/affine-widget-edgeless-auto-connect/index.js +124 -124
  46. package/dist/_pkgs/affine-widget-edgeless-zoom-toolbar/index.js +26 -26
  47. package/dist/_pkgs/affine-widget-keyboard-toolbar/icons.js +98 -98
  48. package/dist/_pkgs/affine-widget-page-dragging-area/index.js +10 -10
  49. package/dist/_pkgs/affine-widget-viewport-overlay/index.js +13 -13
  50. package/dist/_pkgs/data-view/core/group-by/renderer/boolean-group.js +11 -11
  51. package/dist/_pkgs/data-view/core/utils/menu-title.js +17 -17
  52. package/dist/_pkgs/data-view/view-presets/table/pc-virtual/group/top/group-title.js +18 -18
  53. package/package.json +1 -1
@@ -3,11 +3,22 @@ import { Bound, lineIntersects } from '../../global/gfx/index.js';
3
3
  import { GfxGroupLikeElementModel, GfxPrimitiveElementModel, InteractivityExtension, roleIsA, } from '../../std/gfx/index.js';
4
4
  import { effect, signal } from '@preact/signals-core';
5
5
  import { backgroundPlot } from '../framework-background/def.js';
6
- import { backgroundAxisFact, backgroundTransitionBands, backgroundTransitionVisibleProps, } from '../framework-background/facts.js';
6
+ import { backgroundAxisFact, backgroundTransitionBands, backgroundTransitionVisibleProps, containingFrame, } from '../framework-background/facts.js';
7
7
  import { backgroundInVariant } from '../framework-background/labels.js';
8
8
  import { Overlay, OverlayIdentifier } from '../renderer/overlay.js';
9
9
  import { QualityNudgeIdentifier } from './map-quality.js';
10
10
  import { ViolationTimeline } from './violation-timeline.js';
11
+ /**
12
+ * The four scopes from narrowest to widest — the ONLY place the order lives, so
13
+ * "widening only" is a `Math.max` over two indexes rather than a test somebody
14
+ * has to remember to write.
15
+ */
16
+ const SCOPE_ORDER = [
17
+ 'element',
18
+ 'relations',
19
+ 'frame',
20
+ 'surface',
21
+ ];
11
22
  /**
12
23
  * Severity `profile` gives `rule` — the rule's own when the profile is silent
13
24
  * about it, or when there is no profile at all.
@@ -190,6 +201,49 @@ function isRuleSilent(rule, chosen, index) {
190
201
  }
191
202
  return true;
192
203
  }
204
+ /**
205
+ * Whether a finding at `severity` is one the DRAWING user is shown. `'audit'`
206
+ * is collected and never drawn, `'off'` is not raised at all — a level that
207
+ * silences a rule is not a level that shows it.
208
+ */
209
+ function isDrawn(severity) {
210
+ return severity !== 'audit' && severity !== 'off';
211
+ }
212
+ /**
213
+ * Whether `rule` is `'audit'` (or `'off'`) under EVERY profile in play on this
214
+ * surface — in which case no gesture can ever draw one of its findings, and it
215
+ * is evaluated on demand instead ({@link momentOf}).
216
+ *
217
+ * The exact walk {@link isRuleSilent} does, for the other severity that decides
218
+ * something before an element is read: the framework's default profile counts
219
+ * unconditionally, and then every profile a root instance on this surface has
220
+ * CHOSEN. A frame raised to a level that shows the finding therefore puts the
221
+ * rule back on the drawing path — for the whole pass, on the next gesture,
222
+ * because `validationProfile` is a {@link VERDICT_PROPS} and the choice wakes
223
+ * the evaluation that re-asks this question.
224
+ *
225
+ * No profile registered at all ({@link ProfileIndex} absent) means the rule's
226
+ * own declaration is the whole answer.
227
+ */
228
+ function isRuleAuditOnly(rule, chosen, index) {
229
+ if (index === null)
230
+ return !isDrawn(rule.severity);
231
+ if (isDrawn(profileSeverity(rule, index.defaults.get(rule.framework)))) {
232
+ return false;
233
+ }
234
+ if (chosen === null)
235
+ return true;
236
+ for (const id of chosen.values()) {
237
+ const profile = index.byId.get(id);
238
+ // Unknown id, or another framework's: falls back to the default, already
239
+ // known to draw nothing.
240
+ if (profile === undefined || profile.framework !== rule.framework)
241
+ continue;
242
+ if (isDrawn(profileSeverity(rule, profile)))
243
+ return false;
244
+ }
245
+ return true;
246
+ }
193
247
  /**
194
248
  * Re-judge what a rule raised, background by background: rewrite each finding's
195
249
  * severity to what its own profile says, and drop the ones the profile turned
@@ -273,6 +327,11 @@ function backgroundsOf(rule, elements) {
273
327
  * full sweep. An on-demand rule never takes part in that pass, so the frames it
274
328
  * measures against have nothing to invalidate — and a framework whose only rules
275
329
  * are on-demand has no incremental pass to protect in the first place.
330
+ *
331
+ * The DECLARED moment, deliberately, and not the one the levels in force decide
332
+ * ({@link momentOf}): this is a set of ids and not a verdict, so an audit rule
333
+ * whose frames it guards costs one extra id and can never make an answer wrong,
334
+ * where reading the levels here would mean re-reading the surface for it.
276
335
  */
277
336
  export function backgroundElementIds(rules, elements) {
278
337
  const ids = new Set();
@@ -469,15 +528,19 @@ function raise(rule, elementIds, backgroundId, words = rule) {
469
528
  * Cost: two linear passes, one `elementBound` per candidate. Backgrounds are
470
529
  * counted in units, so the per-element cost is constant in practice.
471
530
  */
472
- function evaluateElementInBackground(rule, elements) {
531
+ function evaluateElementInBackground(rule, elements, incremental) {
473
532
  const subjectRole = rule.appliesTo;
474
533
  if (subjectRole === undefined)
475
534
  return [];
476
535
  const backgrounds = backgroundsOf(rule, elements);
477
536
  if (backgrounds.length === 0)
478
537
  return [];
538
+ // Subjects filter here; the `backgrounds` index above stays whole.
539
+ const judged = incremental?.subjects;
479
540
  const violations = [];
480
541
  for (const el of elements) {
542
+ if (judged !== undefined && !judged.has(el.id))
543
+ continue;
481
544
  // Cheapest possible exit for a neutral element: no role, no evaluation.
482
545
  if (el.role === undefined)
483
546
  continue;
@@ -702,7 +765,7 @@ function centreOf(bound) {
702
765
  * Cost: one `elementBound` and one path read per subject, plus the frame
703
766
  * attribution. Constant per element — the frames are counted in units.
704
767
  */
705
- function evaluateOrientationAgainstAxis(rule, elements) {
768
+ function evaluateOrientationAgainstAxis(rule, elements, incremental) {
706
769
  const subjectRole = rule.appliesTo;
707
770
  const against = rule.against;
708
771
  const def = rule.background;
@@ -719,8 +782,12 @@ function evaluateOrientationAgainstAxis(rule, elements) {
719
782
  // Beyond this angle from the axis' forward sense, the subject runs against
720
783
  // it. Perpendicular (90°) plus the declared dead zone.
721
784
  const limit = Math.cos(((90 + against.toleranceDeg) * Math.PI) / 180);
785
+ // Subjects filter here; the `backgrounds` index above stays whole.
786
+ const judged = incremental?.subjects;
722
787
  const violations = [];
723
788
  for (const el of elements) {
789
+ if (judged !== undefined && !judged.has(el.id))
790
+ continue;
724
791
  if (el.role === undefined)
725
792
  continue;
726
793
  if (!roleIsA(el.role, subjectRole, rule.roles))
@@ -1198,10 +1265,12 @@ function pathsCross(a, b) {
1198
1265
  *
1199
1266
  * ## Cost, and the dirty set
1200
1267
  *
1201
- * Naive it is O(p²) over the PARTICIPANTS — the elements carrying one of the
1202
- * declared roles, never the whole surface — with every bound read exactly once
1203
- * and a rectangle test as the inner loop. That is what the bench measures on
1204
- * the reference map, and it fits.
1268
+ * The full pass is a SWEEP-AND-PRUNE over the PARTICIPANTS — the elements
1269
+ * carrying one of the declared roles, never the whole surface. Sorted by left
1270
+ * edge, each subject is tested only against the ones that begin before it ends,
1271
+ * so what used to be p²/2 rectangle tests is now p log p plus the couples that
1272
+ * actually share an x band. That is what the bench measures on the reference
1273
+ * map, and it fits with room to spare.
1205
1274
  *
1206
1275
  * With an {@link IncrementalContext} it is O(|dirty| × p): findings that name
1207
1276
  * only untouched elements are carried over from the previous evaluation, and
@@ -1269,11 +1338,76 @@ function evaluateNoOverlap(rule, elements, incremental) {
1269
1338
  const [first, second] = a.id < b.id ? [a, b] : [b, a];
1270
1339
  found.push(raise(rule, [first.id, second.id], attributeBackground(first.bound, backgrounds)?.id));
1271
1340
  };
1341
+ /**
1342
+ * The full pass: sweep-and-prune on x, not every couple.
1343
+ *
1344
+ * Sorted by left edge, a subject only has to be tested against the ones whose
1345
+ * left edge is still inside its own right edge — past that, every remaining
1346
+ * subject starts further right than this one ends, and the list is sorted, so
1347
+ * the walk stops rather than skips.
1348
+ *
1349
+ * The prune is exactly conservative, and not by geometric luck:
1350
+ * {@link subjectsCollide} opens with `boundsOverlap`, which needs
1351
+ * `a.maxX - b.minX > OVERLAP_EPSILON`. A pair the walk stops at has
1352
+ * `b.minX > a.maxX`, i.e. that difference NEGATIVE — a pair the naive sweep
1353
+ * also answered `false` for, whatever geometry it carried. So an `edge`
1354
+ * subject whose bound did not enclose its path would already be missed by the
1355
+ * naive sweep for the same reason, and the prune cannot lose a crossing the
1356
+ * old code found. (In production it does enclose it: a connector's `xywh` IS
1357
+ * the bounding box of its path — `updatePath` in the connector manager — so
1358
+ * nothing is lost either way.) The stop is `>` rather than `>=` so a
1359
+ * zero-width box sitting exactly on another's right edge is still handed to
1360
+ * `subjectsCollide`, which is the half that owns the touching-versus-
1361
+ * overlapping question.
1362
+ *
1363
+ * Inside the band, the y gate is the same two comparisons `boundsOverlap`
1364
+ * would make, on the same `Bound` fields, with the same epsilon and the same
1365
+ * strict `>` — written out so the ~91 % of surviving couples that miss on y
1366
+ * die before `declared` builds its closures. A pair it skips is a pair
1367
+ * `subjectsCollide` returns `false` for on its first line.
1368
+ *
1369
+ * ponytail: one SORTED axis only — y is a filter inside the band, not an
1370
+ * index. The remaining super-linear term is the DENSITY of subjects sharing
1371
+ * an x band: a board where every subject spans the full width still walks
1372
+ * p²/2 couples, and nothing here notices. It walks them at two subtractions
1373
+ * each now rather than at a `declared` closure, which is a constant and not a
1374
+ * class. The upgrade path when a real board does that is to make y an index
1375
+ * too (sort on it as well and intersect the two candidate sets) or a uniform
1376
+ * grid keyed on the bound; both cost memory per pass, which is why neither is
1377
+ * here yet.
1378
+ */
1272
1379
  const sweep = () => {
1273
- for (let i = 0; i < subjects.length; i++) {
1274
- for (let j = i + 1; j < subjects.length; j++)
1275
- test(subjects[i], subjects[j]);
1380
+ const order = subjects.map((_, index) => index);
1381
+ order.sort((a, b) => subjects[a].bound.x - subjects[b].bound.x);
1382
+ for (let i = 0; i < order.length; i++) {
1383
+ const a = subjects[order[i]];
1384
+ const reach = a.bound.maxX;
1385
+ const aMinY = a.bound.y;
1386
+ const aMaxY = a.bound.y + a.bound.h;
1387
+ for (let j = i + 1; j < order.length; j++) {
1388
+ const b = subjects[order[j]];
1389
+ if (b.bound.x > reach)
1390
+ break;
1391
+ // the cheap axis first; `declared` allocates and runs after
1392
+ const bMinY = b.bound.y;
1393
+ if (bMinY + b.bound.h - aMinY <= OVERLAP_EPSILON ||
1394
+ aMaxY - bMinY <= OVERLAP_EPSILON)
1395
+ continue;
1396
+ test(a, b);
1397
+ }
1276
1398
  }
1399
+ // Findings come out in x order now rather than in document order. Nothing
1400
+ // reads them ordered — every consumer and every spec keys or sorts them —
1401
+ // but a pass whose output depends on how its input happened to be sorted is
1402
+ // a bad thing to leave lying around, so one line pins it. The ids of a pair
1403
+ // are already sorted against each other by `test`.
1404
+ found.sort((x, y) => x.elementIds[0] !== y.elementIds[0]
1405
+ ? x.elementIds[0] < y.elementIds[0]
1406
+ ? -1
1407
+ : 1
1408
+ : x.elementIds[1] < y.elementIds[1]
1409
+ ? -1
1410
+ : 1);
1277
1411
  return found;
1278
1412
  };
1279
1413
  if (incremental === undefined)
@@ -1299,11 +1433,17 @@ function evaluateNoOverlap(rule, elements, incremental) {
1299
1433
  /**
1300
1434
  * ...and so does a change big enough that the shortcut is no longer one.
1301
1435
  *
1302
- * The dirty path costs `dirtyParticipants × p`; the sweep costs `p²/2`. Past
1303
- * the crossover the "optimisation" is several times the price of the thing it
1304
- * replaces — measured at 8× and OUT of the 16 ms budget for a lasso drag over
1305
- * a third of a 500-element map, against 2.2 ms for the sweep it was avoiding.
1306
- * One comparison buys back the worst case entirely.
1436
+ * The dirty path costs `dirtyParticipants × p`; the sweep costs `p²/2` at
1437
+ * worst and much less than that since it prunes on x. Past the crossover the
1438
+ * "optimisation" is several times the price of the thing it replaces —
1439
+ * measured at 8× and OUT of the 16 ms budget for a lasso drag over a third of
1440
+ * a 500-element map, against 2.2 ms for the sweep it was avoiding. One
1441
+ * comparison buys back the worst case entirely, and the pruned sweep only
1442
+ * makes the comparison more conservative: the branch it falls back to is
1443
+ * cheaper than the one this half of the ratio was calibrated against, never
1444
+ * dearer. The dirty loop itself is left NAIVE on purpose — it is the
1445
+ * independent oracle the fuzz in `validation-incremental.unit.spec.ts`
1446
+ * compares the pruned sweep against.
1307
1447
  */
1308
1448
  let dirtyParticipants = 0;
1309
1449
  const isDirty = subjects.map(subject => {
@@ -1523,7 +1663,7 @@ function departsFromTones(el, allowed) {
1523
1663
  * and indicting the whole board over a broken declaration is the one outcome
1524
1664
  * worse than silence.
1525
1665
  */
1526
- function evaluateToneConvention(rule, elements) {
1666
+ function evaluateToneConvention(rule, elements, incremental) {
1527
1667
  const subjectRole = rule.appliesTo;
1528
1668
  const tone = rule.tone;
1529
1669
  const def = rule.background;
@@ -1548,8 +1688,12 @@ function evaluateToneConvention(rule, elements) {
1548
1688
  return [];
1549
1689
  }
1550
1690
  const backgrounds = backgroundsOf(rule, elements);
1691
+ // Subjects filter here; the `backgrounds` index above stays whole.
1692
+ const judged = incremental?.subjects;
1551
1693
  const violations = [];
1552
1694
  for (const el of elements) {
1695
+ if (judged !== undefined && !judged.has(el.id))
1696
+ continue;
1553
1697
  if (el.role === undefined)
1554
1698
  continue;
1555
1699
  if (!roleIsA(el.role, subjectRole, rule.roles))
@@ -1578,7 +1722,7 @@ function evaluateToneConvention(rule, elements) {
1578
1722
  * and the rule ships inert rather than waiting in a branch somewhere for
1579
1723
  * somebody to remember it.
1580
1724
  */
1581
- function evaluateMajorityFact(rule, elements) {
1725
+ function evaluateMajorityFact(rule, elements, incremental) {
1582
1726
  const subjectRole = rule.appliesTo;
1583
1727
  const majority = rule.majority;
1584
1728
  if (subjectRole === undefined || majority === undefined)
@@ -1610,10 +1754,16 @@ function evaluateMajorityFact(rule, elements) {
1610
1754
  if (fact === majority.value)
1611
1755
  counts.matching += 1;
1612
1756
  }
1757
+ // Subjects filter below; the TALLY above stays whole — a majority counted
1758
+ // over a dirty subset is not a majority. The subject here is the FRAME, which
1759
+ // is what the finding is raised on.
1760
+ const judged = incremental?.subjects;
1613
1761
  const violations = [];
1614
1762
  // Sorted, so a board with two qualifying maps always reports them the same
1615
1763
  // way whichever order the surface happened to be walked in.
1616
1764
  for (const id of [...tally.keys()].sort()) {
1765
+ if (judged !== undefined && !judged.has(id))
1766
+ continue;
1617
1767
  const counts = tally.get(id);
1618
1768
  // THE GATE: nothing here carries the fact, so there is no majority to be in.
1619
1769
  if (counts.known === 0)
@@ -1698,7 +1848,7 @@ function boundEnds(el) {
1698
1848
  * two edges and is judged as two edges: at least one of them is against the
1699
1849
  * order, and that one is reported.
1700
1850
  */
1701
- function evaluateRelativeOrder(rule, elements) {
1851
+ function evaluateRelativeOrder(rule, elements, incremental) {
1702
1852
  const order = rule.relativeOrder;
1703
1853
  const def = rule.background;
1704
1854
  if (order === undefined || def === undefined)
@@ -1732,8 +1882,13 @@ function evaluateRelativeOrder(rule, elements) {
1732
1882
  const centre = centreOf(bound);
1733
1883
  return centre[0] * axis.forward[0] + centre[1] * axis.forward[1];
1734
1884
  };
1885
+ // Subjects filter here; the `byId` index above stays whole, or an end outside
1886
+ // the subset would look like a deleted element.
1887
+ const judged = incremental?.subjects;
1735
1888
  const violations = [];
1736
1889
  for (const edge of edges) {
1890
+ if (judged !== undefined && !judged.has(edge.id))
1891
+ continue;
1737
1892
  const ends = boundEnds(edge);
1738
1893
  if (ends === null)
1739
1894
  continue;
@@ -1875,12 +2030,13 @@ function namedElements(ids) {
1875
2030
  * pair-wise sweep over the nodes: the rule is about relations somebody drew,
1876
2031
  * never about every couple that could have had one.
1877
2032
  *
1878
- * An {@link IncrementalContext} is deliberately NOT honoured, exactly like
1879
- * `relative-order-along-axis`: a dirty set names the elements that moved, and
1880
- * this family's verdict does not depend on where anything is. Re-reading every
1881
- * edge is the cost of the linear pass it already was.
2033
+ * Since PF5.4 an {@link IncrementalContext} filters the EDGES it judges, and
2034
+ * the two-hop `'relations'` closure is what makes the pair walk below still
2035
+ * right: a dirty edge puts its ends in the closure, and every SIBLING edge on
2036
+ * those ends comes in with them — which is exactly what the duplicate and
2037
+ * exclusivity verdicts read.
1882
2038
  */
1883
- function evaluateRelationEndpoints(rule, elements) {
2039
+ function evaluateRelationEndpoints(rule, elements, incremental) {
1884
2040
  const endpoints = rule.endpoints;
1885
2041
  if (endpoints === undefined)
1886
2042
  return [];
@@ -1959,12 +2115,17 @@ function evaluateRelationEndpoints(rule, elements) {
1959
2115
  // would have allocated all three just to drop them. This runs on the gesture
1960
2116
  // path, inside the 16 ms the drawing has.
1961
2117
  const needsPairs = endpoints.forbidDuplicate === true || exclusivePairs.length > 0;
2118
+ // Subjects filter here and on the neutral pass below; the `byId` index and
2119
+ // the two role lists above stay whole.
2120
+ const judged = incremental?.subjects;
1962
2121
  const violations = [];
1963
2122
  // Every relation drawn between one UNORDERED pair of elements, which is the
1964
2123
  // grain both the duplicate and the exclusivity questions are asked at. Filled
1965
2124
  // in the same pass that judges the sentences, walked once afterwards.
1966
2125
  const between = new Map();
1967
2126
  for (const { el: edge, role } of edges) {
2127
+ if (judged !== undefined && !judged.has(edge.id))
2128
+ continue;
1968
2129
  const ends = endpointIds(edge);
1969
2130
  if (ends === null)
1970
2131
  continue;
@@ -2039,6 +2200,8 @@ function evaluateRelationEndpoints(rule, elements) {
2039
2200
  // else — a link from an artefact to itself is not evidence that a typed
2040
2201
  // relation was meant.
2041
2202
  for (const el of neutral) {
2203
+ if (judged !== undefined && !judged.has(el.id))
2204
+ continue;
2042
2205
  const ends = boundEnds(el);
2043
2206
  if (ends === null)
2044
2207
  continue;
@@ -2171,14 +2334,7 @@ function zoneRegions(def, zoneIds, frame) {
2171
2334
  * answer decides where a persisted arbitration is written.
2172
2335
  */
2173
2336
  function containingBackground(bound, backgrounds) {
2174
- let found = null;
2175
- for (const background of backgrounds) {
2176
- if (!background.bound.contains(bound))
2177
- continue;
2178
- if (found === null || background.id < found.id)
2179
- found = background;
2180
- }
2181
- return found;
2337
+ return containingFrame(bound, backgrounds, background => background.bound);
2182
2338
  }
2183
2339
  /**
2184
2340
  * "Is this artefact in the region it belongs in?" — the zone half of
@@ -2203,7 +2359,7 @@ function containingBackground(bound, backgrounds) {
2203
2359
  * a frame of reference that is still fully on screen. Judge the drawing that is
2204
2360
  * on the canvas — and a hidden tint does not take a quadrant off it.
2205
2361
  */
2206
- function evaluateElementInZone(rule, elements) {
2362
+ function evaluateElementInZone(rule, elements, incremental) {
2207
2363
  const subjectRole = rule.appliesTo;
2208
2364
  const inZone = rule.inZone;
2209
2365
  const def = rule.background;
@@ -2234,8 +2390,12 @@ function evaluateElementInZone(rule, elements) {
2234
2390
  // right only for as long as nobody moved this map out of one evaluation.
2235
2391
  const regionsOf = new Map();
2236
2392
  const extent = inZone.measure === 'extent';
2393
+ // Subjects filter here; the `backgrounds` index above stays whole.
2394
+ const judged = incremental?.subjects;
2237
2395
  const violations = [];
2238
2396
  for (const el of elements) {
2397
+ if (judged !== undefined && !judged.has(el.id))
2398
+ continue;
2239
2399
  if (el.role === undefined)
2240
2400
  continue;
2241
2401
  if (!roleIsA(el.role, subjectRole, rule.roles))
@@ -2351,7 +2511,7 @@ function evaluateElementInZone(rule, elements) {
2351
2511
  * walk, nothing quadratic — the same budget `element-in-background` and
2352
2512
  * `role-count` are measured on.
2353
2513
  */
2354
- function evaluateViewAdmissibility(rule, elements) {
2514
+ function evaluateViewAdmissibility(rule, elements, incremental) {
2355
2515
  const def = rule.admissibility;
2356
2516
  if (def === undefined)
2357
2517
  return [];
@@ -2381,8 +2541,12 @@ function evaluateViewAdmissibility(rule, elements) {
2381
2541
  // is walked.
2382
2542
  if (views.length === 0)
2383
2543
  return [];
2544
+ // Subjects filter here; the `views` index above stays whole.
2545
+ const judged = incremental?.subjects;
2384
2546
  const violations = [];
2385
2547
  for (const el of elements) {
2548
+ if (judged !== undefined && !judged.has(el.id))
2549
+ continue;
2386
2550
  // Cheapest possible exit for a neutral element: no role, no evaluation.
2387
2551
  const role = el.role;
2388
2552
  if (role === undefined)
@@ -2465,7 +2629,7 @@ function evaluateViewAdmissibility(rule, elements) {
2465
2629
  * No pair-wise sweep and no closure: the rule is about the relations somebody
2466
2630
  * drew, never about the ones they could have drawn.
2467
2631
  */
2468
- function evaluateEdgeDegree(rule, elements) {
2632
+ function evaluateEdgeDegree(rule, elements, incremental) {
2469
2633
  const subjectRole = rule.appliesTo;
2470
2634
  const degree = rule.degree;
2471
2635
  if (subjectRole === undefined || degree === undefined)
@@ -2531,8 +2695,13 @@ function evaluateEdgeDegree(rule, elements) {
2531
2695
  // Only resolved when something is actually wrong: a conformant board is the
2532
2696
  // common case and must not pay for the frames it never names.
2533
2697
  let backgrounds = null;
2698
+ // Subjects filter here; the DEGREE counts above are tallied over every edge
2699
+ // on the surface, or a node's degree would be the degree of its neighbourhood.
2700
+ const judged = incremental?.subjects;
2534
2701
  const violations = [];
2535
2702
  for (const subject of subjects) {
2703
+ if (judged !== undefined && !judged.has(subject.el.id))
2704
+ continue;
2536
2705
  const words = minIn !== undefined && subject.in < minIn
2537
2706
  ? (degree.tooFewIn ?? rule)
2538
2707
  : maxIn !== undefined && subject.in > maxIn
@@ -2605,7 +2774,7 @@ function evaluateEdgeDegree(rule, elements) {
2605
2774
  * subject per frame — the frames are counted in units, so this is constant per
2606
2775
  * element in practice, like every other attribution in this file.
2607
2776
  */
2608
- function evaluateRoleCount(rule, elements) {
2777
+ function evaluateRoleCount(rule, elements, incremental) {
2609
2778
  const count = rule.roleCount;
2610
2779
  if (count === undefined)
2611
2780
  return [];
@@ -2653,10 +2822,16 @@ function evaluateRoleCount(rule, elements) {
2653
2822
  if (arms)
2654
2823
  armed.add(frame.id);
2655
2824
  }
2825
+ // Subjects filter below; the TALLY above stays whole — a count over a dirty
2826
+ // subset is not a count. The subject here is the FRAME, which is what the
2827
+ // finding is raised on.
2828
+ const judged = incremental?.subjects;
2656
2829
  const violations = [];
2657
2830
  // Sorted, so a board with several offending frames always reports them the
2658
2831
  // same way whichever order the surface happened to be walked in.
2659
2832
  for (const id of [...tally.keys()].sort()) {
2833
+ if (judged !== undefined && !judged.has(id))
2834
+ continue;
2660
2835
  // A frame the guard has not armed is not judged at all — not walked, not
2661
2836
  // found compliant. That is the difference between "this notation requires
2662
2837
  // one" and "this notation requires one WHEN the other is there".
@@ -2714,7 +2889,7 @@ function evaluateRoleCount(rule, elements) {
2714
2889
  * Linear in the elements plus linear in the edges, with the plots resolved once
2715
2890
  * per frame.
2716
2891
  */
2717
- function evaluateEdgeLocality(rule, elements) {
2892
+ function evaluateEdgeLocality(rule, elements, incremental) {
2718
2893
  const locality = rule.locality;
2719
2894
  if (locality === undefined)
2720
2895
  return [];
@@ -2737,8 +2912,12 @@ function evaluateEdgeLocality(rule, elements) {
2737
2912
  if (edges.length === 0)
2738
2913
  return [];
2739
2914
  const same = locality.mode === 'same-background';
2915
+ // Subjects filter here; the `byId` index and the frame plots above stay whole.
2916
+ const judged = incremental?.subjects;
2740
2917
  const violations = [];
2741
2918
  for (const edge of edges) {
2919
+ if (judged !== undefined && !judged.has(edge.id))
2920
+ continue;
2742
2921
  const ends = boundEnds(edge);
2743
2922
  if (ends === null)
2744
2923
  continue;
@@ -3025,7 +3204,7 @@ function elementLabel(el) {
3025
3204
  * One pass, one `text` read per subject. Linear, and paid only by the elements
3026
3205
  * carrying the subject role.
3027
3206
  */
3028
- function evaluateLabelPresence(rule, elements) {
3207
+ function evaluateLabelPresence(rule, elements, incremental) {
3029
3208
  const subjectRole = rule.appliesTo;
3030
3209
  const label = rule.label;
3031
3210
  if (subjectRole === undefined || label === undefined)
@@ -3035,8 +3214,12 @@ function evaluateLabelPresence(rule, elements) {
3035
3214
  // Only resolved when something is actually wrong — a named board pays nothing
3036
3215
  // for the frames it never has to name.
3037
3216
  let backgrounds = null;
3217
+ // Subjects filter here; nothing above it is an index this family shares.
3218
+ const judged = incremental?.subjects;
3038
3219
  const violations = [];
3039
3220
  for (const el of elements) {
3221
+ if (judged !== undefined && !judged.has(el.id))
3222
+ continue;
3040
3223
  // Cheapest possible exit for a neutral element: no role, no evaluation.
3041
3224
  if (el.role === undefined)
3042
3225
  continue;
@@ -3067,7 +3250,567 @@ const RULE_FAMILIES = {
3067
3250
  'label-presence': evaluateLabelPresence,
3068
3251
  'view-admissibility': evaluateViewAdmissibility,
3069
3252
  };
3070
- /** A rule the drawing path evaluates. `moment` absent means `'realtime'`. */
3253
+ /**
3254
+ * What a verdict of each family DEPENDS ON (PF5.3) — read off the evaluator
3255
+ * beside it, one line of justification each.
3256
+ *
3257
+ * `Record<RuleFamily, RuleScope>`, so a family added without an entry is a
3258
+ * BUILD error and never a silent `'surface'`. Nothing reads this table yet:
3259
+ * PF5.4 will compute its dirty-set closure from it, and a family that changes
3260
+ * what its evaluator reads has to change its line here on the same commit.
3261
+ *
3262
+ * Over-approximating is always SAFE — a wider scope re-evaluates more than it
3263
+ * had to. Under-approximating is a stale verdict, so a family whose evidence is
3264
+ * ambiguous is declared wider, not narrower.
3265
+ */
3266
+ export const RULE_SCOPES = {
3267
+ // Own bound against the frames; the frame it is nearest to is attribution only.
3268
+ 'element-in-background': 'element',
3269
+ // Own direction against an axis the RULE declares; the frame is attribution only.
3270
+ 'orientation-against-axis': 'element',
3271
+ // Widened from 'frame': the carriers it must be posed ON are collected from
3272
+ // the WHOLE surface, bounded by a tolerance and by no frame at all, so a
3273
+ // carrier moving anywhere can flip a subject's verdict.
3274
+ attachment: 'surface',
3275
+ // Pair-wise over every participant on the board; keeps its own dirty-set logic.
3276
+ 'no-overlap': 'surface',
3277
+ // Element-local in fact — own colour props against the rule's declared palette
3278
+ // — but kept at the wider level: no evaluator has ever been narrowed by guess.
3279
+ 'tone-convention': 'frame',
3280
+ // Tallies the fact over every subject attributed to one frame.
3281
+ 'majority-fact': 'frame',
3282
+ // The edge, the bounds of the two elements it binds, and their common frame.
3283
+ 'relative-order-along-axis': 'relations',
3284
+ // The ends' roles, plus the sibling edges on the same pair (duplicate,
3285
+ // exclusivity) — one hop through the two ends.
3286
+ 'relation-endpoints': 'relations',
3287
+ // Own bound against the zones of the frame that contains it.
3288
+ 'element-in-zone': 'element',
3289
+ // Counts every edge naming the node, wherever that edge is drawn.
3290
+ 'edge-degree': 'relations',
3291
+ // Counts the subjects contained in one frame's plot, and reports ON the frame.
3292
+ 'role-count': 'frame',
3293
+ // The edge and the frames containing its two ends.
3294
+ 'edge-locality': 'relations',
3295
+ // Widened from 'frame': the traversal is over the WHOLE surface's arcs, and
3296
+ // its own header says any dirty edge invalidates the answer — re-pointing one
3297
+ // edge orphans or rescues nodes nowhere near it.
3298
+ reachability: 'surface',
3299
+ // Own words; the frame is attribution only.
3300
+ 'label-presence': 'element',
3301
+ // Own role against the level the containing view declares; kept at the wider
3302
+ // level for the same reason as `tone-convention`.
3303
+ 'view-admissibility': 'frame',
3304
+ };
3305
+ /**
3306
+ * The dependency scope in force for `rule`: the WIDER of its family's and its
3307
+ * own.
3308
+ *
3309
+ * Widening-only by construction rather than by a test — the answer is a
3310
+ * `Math.max` over two positions in {@link SCOPE_ORDER}, so a rule declaring a
3311
+ * narrower scope than its family simply does not get it.
3312
+ *
3313
+ * An unknown family — a serialized rule shipped by a host against a build that
3314
+ * does not have it — resolves to `'surface'`, the safe answer.
3315
+ */
3316
+ export function scopeOf(rule) {
3317
+ const family = RULE_SCOPES[rule.family] ?? 'surface';
3318
+ if (rule.scope === undefined)
3319
+ return family;
3320
+ const widest = Math.max(SCOPE_ORDER.indexOf(family), SCOPE_ORDER.indexOf(rule.scope));
3321
+ // A `scope` that is not one of the four gives -1 on both sides only if the
3322
+ // family is unknown too; either way the fallback is the widest answer.
3323
+ return SCOPE_ORDER[widest] ?? 'surface';
3324
+ }
3325
+ /**
3326
+ * The key a frame-role GROUP is memoised under: the framework and the role,
3327
+ * never the rule.
3328
+ *
3329
+ * Two rules of one framework framing against the same role look at the same
3330
+ * instances, and resolving them twice would pay a surface walk per rule for one
3331
+ * answer. `null` for a rule that frames against nothing.
3332
+ */
3333
+ function frameKey(rule) {
3334
+ if (rule.backgroundRole === undefined)
3335
+ return null;
3336
+ // Joined by NUL, which no framework id and no role id contains.
3337
+ return `${rule.framework}\u0000${rule.backgroundRole}`;
3338
+ }
3339
+ /**
3340
+ * Every (element, frame) attribution, deliberately OVER-approximated: the frame
3341
+ * {@link attributeBackground} names, plus every frame whose box holds the
3342
+ * element's centre.
3343
+ *
3344
+ * The union of what the frame families actually ask — `attributeBackground`'s
3345
+ * contains-or-nearest, `containingBackground`'s whole containment, and
3346
+ * `plotContaining`'s centre-in-plot — read WITHOUT the plot arithmetic, so one
3347
+ * answer serves every rule framing against the role whatever geometry each one
3348
+ * declares. A plot is a sub-region of the box, so centre-in-plot implies
3349
+ * centre-in-box; whole containment implies it too. Over-approximating widens a
3350
+ * closure and can never lose a verdict.
3351
+ *
3352
+ * ponytail: the two halves are individually redundant on the SEVEN PACKS
3353
+ * SHIPPED TODAY — the fuzz still passes with either one deleted, and fails the
3354
+ * moment both go. They stop being redundant as soon as a framework declares a
3355
+ * background margin big enough to push a contained element's centre out of its
3356
+ * own plot while another frame's plot holds it, which is a geometry no pack has
3357
+ * drawn yet and none is stopped from drawing. Two `Bound` tests per element,
3358
+ * over frames counted in units; not worth trading for an argument that has to
3359
+ * be re-derived every time a pack adds a margin.
3360
+ */
3361
+ function attributeAll(elements, frames, add) {
3362
+ if (frames.length === 0)
3363
+ return;
3364
+ for (const el of elements) {
3365
+ const bound = el.elementBound;
3366
+ const attributed = attributeBackground(bound, frames);
3367
+ if (attributed !== null)
3368
+ add(el.id, attributed.id);
3369
+ const centre = centreOf(bound);
3370
+ for (const frame of frames) {
3371
+ if (frame.bound.containsPoint(centre))
3372
+ add(el.id, frame.id);
3373
+ }
3374
+ }
3375
+ }
3376
+ /**
3377
+ * The frames each element is attributed to, for the FRAME-scope rules
3378
+ * registered — the manager's memory of a pass, handed to the next one as
3379
+ * {@link IncrementalContext.wasIn}.
3380
+ *
3381
+ * The one fact the current surface cannot reconstruct: an element that has left
3382
+ * a frame no longer says where it was, and the frame it left has one fewer
3383
+ * subject to count. Without it a `'frame'`-scope rule has no honest incremental
3384
+ * answer at all, which is exactly what {@link dirtyClosure} falls back to.
3385
+ *
3386
+ * ## Two depths, and why the shallow one is not optional
3387
+ *
3388
+ * The full attribution — which frame every element is on — is built for the
3389
+ * `'frame'` scope only, since no other scope reads it. A framework like Wardley,
3390
+ * whose rules are element, relations and surface, never pays for that walk.
3391
+ *
3392
+ * But every PIVOTAL element records itself under its own id — the same
3393
+ * statement about a frame ("it is on itself"), asked at no cost beyond a role
3394
+ * read and a prop read. Two kinds are pivotal:
3395
+ *
3396
+ * - a **frame** of any rule that frames against a background;
3397
+ * - an element that **declares a level of requirement** (`validationProfile`),
3398
+ * because the levels in force decide the severity AND the moment of every
3399
+ * rule on the board, and they are INHERITED by containment
3400
+ * ({@link inheritChosenProfiles}) — so one of them is never a fact about one
3401
+ * subject.
3402
+ *
3403
+ * Those entries are what let {@link runRules} and {@link dirtyClosure} tell a
3404
+ * dirty element that WAS one of the two from one that was never anything of the
3405
+ * sort. That is the case a deletion makes unknowable, and that a role or a level
3406
+ * changed AWAY makes invisible even while the element is still there. Without
3407
+ * it, an incremental pass keeps verdicts measured against a map the user has
3408
+ * just stopped calling a map, or judged at a level nobody holds any more.
3409
+ *
3410
+ * Real-time rules only, and the DECLARED moment, exactly like
3411
+ * {@link backgroundElementIds} and for the same reason: an on-demand rule never
3412
+ * takes part in an incremental pass, so it has nothing to invalidate.
3413
+ */
3414
+ export function frameMembership(rules, elements) {
3415
+ const membership = new Map();
3416
+ const add = (id, frameId) => {
3417
+ const held = membership.get(id);
3418
+ if (held === undefined)
3419
+ membership.set(id, [frameId]);
3420
+ else if (!held.includes(frameId))
3421
+ held.push(frameId);
3422
+ };
3423
+ const done = new Set();
3424
+ let levelled = false;
3425
+ for (const rule of rules) {
3426
+ if (!isRealtime(rule))
3427
+ continue;
3428
+ levelled = true;
3429
+ const key = frameKey(rule);
3430
+ if (key === null || done.has(key))
3431
+ continue;
3432
+ done.add(key);
3433
+ const frames = backgroundsOf(rule, elements);
3434
+ // The shallow half: every frame, on itself.
3435
+ for (const frame of frames)
3436
+ add(frame.id, frame.id);
3437
+ if (scopeOf(rule) !== 'frame')
3438
+ continue;
3439
+ attributeAll(elements, frames, add);
3440
+ }
3441
+ // The level-bearers, on themselves. One prop read per element, and only when
3442
+ // there is a rule for a level to govern.
3443
+ if (levelled) {
3444
+ for (const el of elements) {
3445
+ if (typeof el.validationProfile === 'string')
3446
+ add(el.id, el.id);
3447
+ }
3448
+ }
3449
+ return membership;
3450
+ }
3451
+ /**
3452
+ * Whether the LEVELS IN FORCE may have moved since `previous` was computed — in
3453
+ * which case nothing from it may be carried, because it is an answer to a
3454
+ * different question.
3455
+ *
3456
+ * A level is not a fact about one subject: it decides each rule's severity and
3457
+ * its MOMENT ({@link momentOf}), so a change to one can put a whole family on or
3458
+ * off the drawing path at once, and it is inherited by containment, so moving
3459
+ * the frame that carries one re-levels everything it holds. There is no closure
3460
+ * of that; there is only "did anything about it change".
3461
+ *
3462
+ * Answered against the memory {@link frameMembership} keeps: a dirty element
3463
+ * that declares a level NOW, or was pivotal at the last pass. A caller that
3464
+ * keeps no memory gets the first half alone — which is the PF5.13 contract this
3465
+ * slice inherited, unchanged, and never worse than it.
3466
+ */
3467
+ function levelsMayHaveMoved(elements, incremental) {
3468
+ const { dirty, wasIn } = incremental;
3469
+ for (const el of elements) {
3470
+ if (!dirty.has(el.id))
3471
+ continue;
3472
+ if (typeof el.validationProfile === 'string')
3473
+ return true;
3474
+ }
3475
+ if (wasIn === undefined)
3476
+ return false;
3477
+ for (const id of dirty) {
3478
+ if (wasIn.get(id)?.includes(id) === true)
3479
+ return true;
3480
+ }
3481
+ return false;
3482
+ }
3483
+ /**
3484
+ * The closure of a change (PF5.4): for each rule, which subjects a pass must
3485
+ * re-judge for its answer to be the one a FULL pass would give.
3486
+ *
3487
+ * Framework-agnostic by construction — it reads roles only through
3488
+ * {@link backgroundsOf}, ends only through {@link rawEndpointIds}, and geometry
3489
+ * only through `elementBound`. It never asks what anything IS.
3490
+ *
3491
+ * ## The three closures
3492
+ *
3493
+ * - `'element'` — the dirty set itself. A verdict about an element's own props
3494
+ * changes when those props change and at no other time.
3495
+ * - `'relations'` — the dirty set, plus the ends of every dirty edge, plus every
3496
+ * edge touching one of those ends. Two hops, on purpose: a dirty edge reaches
3497
+ * its ends, and its ends reach their SIBLING edges — which is precisely what
3498
+ * the duplicate and exclusivity verdicts of `relation-endpoints` read.
3499
+ * - `'frame'` — the dirty set, plus every frame a dirty element is or WAS
3500
+ * attributed to, plus everything attributed to one of those frames. The frame
3501
+ * ids are in the set themselves, since `role-count` and `majority-fact` raise
3502
+ * their findings ON the frame.
3503
+ *
3504
+ * ## Where it refuses to answer
3505
+ *
3506
+ * Each of these returns `null`, i.e. a full pass for that rule, and each is a
3507
+ * case where a narrower answer would be a GUESS:
3508
+ *
3509
+ * - **a frame of the rule is dirty** — moving, resizing or deleting one
3510
+ * re-attributes every finding measured against it. The deletion is the case a
3511
+ * "is a current background dirty" test cannot see on its own, so the previous
3512
+ * findings are asked too, exactly as {@link evaluateNoOverlap} asks them.
3513
+ * - **a dirty element is GONE, for a `'relations'` rule** — a removal reports an
3514
+ * id and nothing else, so the ends of a deleted edge cannot be recovered, and
3515
+ * the nodes it used to bind would keep a degree nobody counts any more.
3516
+ * - **no `wasIn`, for a `'frame'` rule** — see {@link frameMembership}.
3517
+ *
3518
+ * ## Cost
3519
+ *
3520
+ * Lazy throughout: a board of element-scope rules alone never walks an edge,
3521
+ * and one whose frames nothing touched never builds a membership map. The
3522
+ * relations closure is two passes over the elements, shared by every
3523
+ * relations-scope rule; the frame membership is one pass per frame ROLE, shared
3524
+ * by every rule framing against it.
3525
+ */
3526
+ export function dirtyClosure(elements, dirty, previous, wasIn) {
3527
+ const alive = new Set();
3528
+ for (const el of elements)
3529
+ alive.add(el.id);
3530
+ // A dirty id that is no longer on the surface: whatever it was bound to went
3531
+ // with it, and no walk of what is left can find out what that was.
3532
+ let buried = false;
3533
+ for (const id of dirty) {
3534
+ if (!alive.has(id)) {
3535
+ buried = true;
3536
+ break;
3537
+ }
3538
+ }
3539
+ const framesByKey = new Map();
3540
+ const framesOf = (rule) => {
3541
+ const key = frameKey(rule);
3542
+ if (key === null)
3543
+ return [];
3544
+ let frames = framesByKey.get(key);
3545
+ if (frames === undefined) {
3546
+ frames = backgroundsOf(rule, elements);
3547
+ framesByKey.set(key, frames);
3548
+ }
3549
+ return frames;
3550
+ };
3551
+ /**
3552
+ * Whether anything dirty was a FRAME at the last pass — of any rule, which is
3553
+ * the safe over-approximation and costs one walk of the dirty set.
3554
+ *
3555
+ * A frame records itself in {@link frameMembership}, so "it was a frame" is
3556
+ * "its own id is among the frames it was attributed to". That is the one fact
3557
+ * about a departed or re-typed frame the current surface cannot answer.
3558
+ */
3559
+ let wasFrame;
3560
+ const dirtyWasFrame = () => {
3561
+ if (wasFrame !== undefined)
3562
+ return wasFrame;
3563
+ wasFrame = false;
3564
+ for (const id of dirty) {
3565
+ if (wasIn?.get(id)?.includes(id) === true) {
3566
+ wasFrame = true;
3567
+ break;
3568
+ }
3569
+ }
3570
+ return wasFrame;
3571
+ };
3572
+ const previousByRule = new Map();
3573
+ for (const violation of previous) {
3574
+ const held = previousByRule.get(violation.ruleId);
3575
+ if (held === undefined)
3576
+ previousByRule.set(violation.ruleId, [violation]);
3577
+ else
3578
+ held.push(violation);
3579
+ }
3580
+ let relations;
3581
+ const relationsClosure = () => {
3582
+ if (relations !== undefined)
3583
+ return relations;
3584
+ if (buried)
3585
+ return (relations = null);
3586
+ // One pass, two indexes: what each edge binds, and which edges name each
3587
+ // element. Self-loops included — a loop IS a sentence about its node, and
3588
+ // it counts on both sides of a degree.
3589
+ const endsOf = new Map();
3590
+ const incident = new Map();
3591
+ for (const el of elements) {
3592
+ const ends = rawEndpointIds(el);
3593
+ if (ends === null)
3594
+ continue;
3595
+ endsOf.set(el.id, ends);
3596
+ for (const end of ends) {
3597
+ const held = incident.get(end);
3598
+ if (held === undefined)
3599
+ incident.set(end, [el.id]);
3600
+ else if (!held.includes(el.id))
3601
+ held.push(el.id);
3602
+ }
3603
+ }
3604
+ /**
3605
+ * The closure is exactly this: **every edge naming something in the set is
3606
+ * in the set**.
3607
+ *
3608
+ * Not a fixed number of hops, and the difference is the whole correctness
3609
+ * of the carry-over below. A finding of a relations family names its edge
3610
+ * AND the two elements the edge binds, so it is dropped as soon as any one
3611
+ * of the three is being re-judged — and it is only re-raised if the EDGE is
3612
+ * too. A set that held an element without holding the edges that name it
3613
+ * would therefore lose findings that nothing raises again, which is a
3614
+ * verdict quietly disappearing from a live board.
3615
+ *
3616
+ * Seeded with the dirty set and the ends of every dirty edge, because those
3617
+ * are the two ways a change reaches a relation: the edge itself moved, or
3618
+ * one of the things it binds did.
3619
+ *
3620
+ * On any ordinary board this settles in exactly two hops — an artefact is
3621
+ * not the endpoint of anything — and costs what "the dirty set plus the
3622
+ * edges touching it" costs. It walks further only where an element is an
3623
+ * edge AND an endpoint at once, which is the one shape the two-hop version
3624
+ * got wrong.
3625
+ */
3626
+ const closure = new Set();
3627
+ const queue = [];
3628
+ const visit = (id) => {
3629
+ if (closure.has(id))
3630
+ return;
3631
+ closure.add(id);
3632
+ queue.push(id);
3633
+ };
3634
+ for (const id of dirty)
3635
+ visit(id);
3636
+ for (const id of dirty) {
3637
+ const ends = endsOf.get(id);
3638
+ if (ends === undefined)
3639
+ continue;
3640
+ visit(ends[0]);
3641
+ visit(ends[1]);
3642
+ }
3643
+ // `head` walks the queue rather than shifting it: a shift is O(n) on a real
3644
+ // array, which would turn this linear walk quadratic.
3645
+ for (let head = 0; head < queue.length; head++) {
3646
+ for (const edge of incident.get(queue[head]) ?? [])
3647
+ visit(edge);
3648
+ }
3649
+ return (relations = closure);
3650
+ };
3651
+ const membershipByKey = new Map();
3652
+ const membershipOf = (rule) => {
3653
+ const key = frameKey(rule);
3654
+ let map = membershipByKey.get(key);
3655
+ if (map === undefined) {
3656
+ const built = new Map();
3657
+ attributeAll(elements, framesOf(rule), (id, frameId) => {
3658
+ const held = built.get(id);
3659
+ if (held === undefined)
3660
+ built.set(id, [frameId]);
3661
+ else if (!held.includes(frameId))
3662
+ held.push(frameId);
3663
+ });
3664
+ map = built;
3665
+ membershipByKey.set(key, map);
3666
+ }
3667
+ return map;
3668
+ };
3669
+ /**
3670
+ * The frames whose CONTENT changed, per rule — the only reason a finding
3671
+ * carried over from the last pass can be invalidated by something that is not
3672
+ * one of its own elements.
3673
+ *
3674
+ * Empty for every scope but `'frame'`, and deliberately so: an `'element'` or
3675
+ * `'relations'` verdict reads its frame for ATTRIBUTION only, and any change
3676
+ * to the frame itself already took the rule through a full pass above.
3677
+ */
3678
+ const affectedByRule = new Map();
3679
+ const frameClosure = (rule) => {
3680
+ if (wasIn === undefined)
3681
+ return null;
3682
+ const frames = framesOf(rule);
3683
+ // Nothing to be attributed to: the frame scope collapses to the element one.
3684
+ if (frames.length === 0)
3685
+ return dirty;
3686
+ const mine = new Set(frames.map(frame => frame.id));
3687
+ const membership = membershipOf(rule);
3688
+ const affected = new Set();
3689
+ for (const id of dirty) {
3690
+ // Where it is now...
3691
+ for (const frameId of membership.get(id) ?? []) {
3692
+ if (mine.has(frameId))
3693
+ affected.add(frameId);
3694
+ }
3695
+ // ...and where it was, which is the half the surface cannot answer.
3696
+ for (const frameId of wasIn.get(id) ?? []) {
3697
+ if (mine.has(frameId))
3698
+ affected.add(frameId);
3699
+ }
3700
+ }
3701
+ affectedByRule.set(rule.id, affected);
3702
+ if (affected.size === 0)
3703
+ return dirty;
3704
+ const subjects = new Set(dirty);
3705
+ for (const frameId of affected)
3706
+ subjects.add(frameId);
3707
+ for (const [id, held] of membership) {
3708
+ if (held.some(frameId => affected.has(frameId)))
3709
+ subjects.add(id);
3710
+ }
3711
+ return subjects;
3712
+ };
3713
+ return {
3714
+ subjectsOf(rule) {
3715
+ if (rule.backgroundRole !== undefined) {
3716
+ // No memory of what the frames WERE: a dirty element may have stopped
3717
+ // being one — deleted, or simply re-typed — and every attribution it
3718
+ // held would silently keep the verdict it had. See
3719
+ // {@link frameMembership}; the manager always hands this down.
3720
+ if (wasIn === undefined)
3721
+ return null;
3722
+ // A frame that IS dirty re-attributes everything measured against it...
3723
+ if (framesOf(rule).some(frame => dirty.has(frame.id)))
3724
+ return null;
3725
+ // ...and so does one that WAS, which is what the memory is for.
3726
+ if (dirtyWasFrame())
3727
+ return null;
3728
+ // ...and so does one whose findings are the only trace it left, for a
3729
+ // pass that was handed no memory of the frame but does hold its
3730
+ // findings ({@link evaluateNoOverlap} makes the same test).
3731
+ const mine = previousByRule.get(rule.id);
3732
+ if (mine?.some(violation => violation.backgroundId !== undefined &&
3733
+ dirty.has(violation.backgroundId))) {
3734
+ return null;
3735
+ }
3736
+ }
3737
+ switch (scopeOf(rule)) {
3738
+ case 'element':
3739
+ return dirty;
3740
+ case 'relations':
3741
+ return relationsClosure();
3742
+ case 'frame':
3743
+ return frameClosure(rule);
3744
+ default:
3745
+ return null;
3746
+ }
3747
+ },
3748
+ carried(rule, subjects) {
3749
+ const mine = previousByRule.get(rule.id);
3750
+ if (mine === undefined)
3751
+ return [];
3752
+ const affected = affectedByRule.get(rule.id);
3753
+ return mine.filter(violation => {
3754
+ // Judged again: the pass that judged it owns the answer, whatever it
3755
+ // turns out to be. Keeping this one too would report the finding twice.
3756
+ if (violation.elementIds.some(id => !alive.has(id) || subjects.has(id))) {
3757
+ return false;
3758
+ }
3759
+ const frameId = violation.backgroundId;
3760
+ if (frameId === undefined)
3761
+ return true;
3762
+ // The frame is tested against the AFFECTED set and never against
3763
+ // `subjects`: the relations closure reaches a frame the moment a dirty
3764
+ // edge is bound to one, and dropping every finding attributed to that
3765
+ // frame would lose the ones nothing re-raises. Its ATTRIBUTION cannot
3766
+ // have changed anyway — a dirty frame is a full pass, above.
3767
+ //
3768
+ // ponytail: for the FOUR FRAME-SCOPE FAMILIES SHIPPED TODAY this whole
3769
+ // line is redundant, and the fuzz says so — it passes with the test
3770
+ // reduced to `true`. It holds because each of them raises either ON the
3771
+ // frame or on an element attributed to it, and both are already in
3772
+ // `subjects` whenever the frame is affected. A frame-scope family whose
3773
+ // finding named something attributed ELSEWHERE — the shape
3774
+ // `edge-locality` already has, at a scope it does not yet claim — would
3775
+ // carry a stale verdict without it. One set lookup per carried finding
3776
+ // is the price of not having to re-check that argument per family.
3777
+ return alive.has(frameId) && affected?.has(frameId) !== true;
3778
+ });
3779
+ },
3780
+ };
3781
+ }
3782
+ /**
3783
+ * The moment `rule` is evaluated at, GIVEN the levels of requirement in force
3784
+ * on this surface — the one decision {@link runRules} takes before it reads a
3785
+ * single element.
3786
+ *
3787
+ * A rule that declares the second moment keeps it, whatever any level says: an
3788
+ * explicit `'on-demand'` is the stronger statement, and a framework that made
3789
+ * it is not overruled by a table. Everything else turns on
3790
+ * {@link isRuleAuditOnly}: an `audit` finding is dropped before anything draws
3791
+ * it ({@link userFacingViolations}), so computing one inside the frame budget
3792
+ * buys the user nothing.
3793
+ *
3794
+ * Mixed levels resolve towards EVALUATING, exactly as {@link isRuleSilent}
3795
+ * needs `'off'` everywhere before it skips: one frame held to a level that
3796
+ * shows the finding is enough to put the rule back on the drawing path for the
3797
+ * whole pass, because the pass has one answer and a missed skip costs a linear
3798
+ * walk where a wrong one costs a silent verdict.
3799
+ */
3800
+ function momentOf(rule, chosen, index) {
3801
+ if ((rule.moment ?? 'realtime') !== 'realtime')
3802
+ return 'on-demand';
3803
+ return isRuleAuditOnly(rule, chosen, index) ? 'on-demand' : 'realtime';
3804
+ }
3805
+ /**
3806
+ * A rule the drawing path evaluates on its own DECLARATION alone — `moment`
3807
+ * absent means `'realtime'`.
3808
+ *
3809
+ * The severity question is not asked here: this is the predicate for the two
3810
+ * places that have no instance to read a level from ({@link verdictPropsOf},
3811
+ * {@link backgroundElementIds}), and there the conservative answer is the right
3812
+ * one. {@link momentOf} is what an evaluation asks.
3813
+ */
3071
3814
  function isRealtime(rule) {
3072
3815
  return (rule.moment ?? 'realtime') === 'realtime';
3073
3816
  }
@@ -3133,6 +3876,18 @@ function applyExceptions(rule, raised, elements) {
3133
3876
  * whole surface is re-judged from scratch. Handed in, the pair-wise family
3134
3877
  * re-tests only the couples a change can have affected; the answer is the same,
3135
3878
  * it is just reached without walking the grid against itself.
3879
+ *
3880
+ * ## Two ways to hand one in, and two contracts
3881
+ *
3882
+ * - With a `previous` that IS the last verdict — the manager's debounced path —
3883
+ * the answer is exactly what a full pass would give, whole. That is PF5.4's
3884
+ * contract and the fuzz asserts it finding for finding.
3885
+ * - With `previous: []` — a caller SEEDING the pass with its own dirty set, e.g.
3886
+ * the host's selection (PF7.9) — nothing is carried, so the answer is the
3887
+ * fresh verdicts on the closure of the seed and nothing else. For a seed `S`:
3888
+ * `seeded ⊆ full`, and `seeded ∩ touching(S) = full ∩ touching(S)` where
3889
+ * `touching(S)` is the findings naming an element or a frame of `S`. A partial
3890
+ * answer, never a wrong one.
3136
3891
  */
3137
3892
  export function evaluateRules(rules, elements, profiles = [], incremental) {
3138
3893
  return runRules(rules, elements, profiles, 'realtime', incremental);
@@ -3154,34 +3909,92 @@ function runRules(rules, elements, profiles, moment, incremental) {
3154
3909
  // No profile registered => not a single extra read on the whole surface.
3155
3910
  const index = profiles.length > 0 ? indexProfiles(profiles) : null;
3156
3911
  const chosen = index ? readChosenProfiles(elements) : null;
3912
+ /**
3913
+ * What this pass may build on, which is `incremental` unless the LEVELS in
3914
+ * force may have moved under it.
3915
+ *
3916
+ * They are a property of the whole pass and not of any subject: a level
3917
+ * decides each rule's severity and its MOMENT, so one change to one of them
3918
+ * can put a whole family on or off the drawing path, and `previous` is then
3919
+ * an answer to a different question — empty for a family that was on the
3920
+ * other moment, which no carry-over and no dirty-set walk can reconstruct. So
3921
+ * the answer is not "re-judge more", it is "there is nothing here to build
3922
+ * on", and that applies to {@link evaluateNoOverlap} exactly as it does to
3923
+ * the twelve families that filter on subjects.
3924
+ *
3925
+ * A board with no profile registered has no regime to change and pays nothing
3926
+ * for the question.
3927
+ */
3928
+ const context = incremental !== undefined &&
3929
+ index !== null &&
3930
+ levelsMayHaveMoved(elements, incremental)
3931
+ ? undefined
3932
+ : incremental;
3933
+ /**
3934
+ * The closure of the change, built ONCE for the whole pass and lazily inside
3935
+ * ({@link dirtyClosure}) — `null` when this pass has nothing to build it from.
3936
+ *
3937
+ * The crossover guard is {@link evaluateNoOverlap}'s, for the same reason and
3938
+ * as one comparison: past half the surface, deriving what NOT to walk costs
3939
+ * more than walking it. `no-overlap` keeps its own guard on its own
3940
+ * participants, so it is not weakened by this one being coarser.
3941
+ */
3942
+ const closure = context === undefined || context.dirty.size * 2 >= elements.length
3943
+ ? null
3944
+ : dirtyClosure(elements, context.dirty, context.previous, context.wasIn);
3157
3945
  const violations = [];
3158
3946
  for (const rule of rules) {
3159
3947
  // The other moment's rules are not this pass's business — checked FIRST,
3160
- // so an on-demand rule never reaches a profile lookup, let alone an
3161
- // element (PF5.14).
3162
- if ((isRealtime(rule) ? 'realtime' : 'on-demand') !== moment)
3948
+ // so an on-demand rule never reaches a family, let alone an element
3949
+ // (PF5.14). The levels in force are already read, once, above: deciding the
3950
+ // moment against them costs two map lookups and no walk (PF7.6).
3951
+ if (momentOf(rule, chosen, index) !== moment)
3163
3952
  continue;
3164
3953
  // `'off'` everywhere means never walked: the cheapest exit there is, taken
3165
3954
  // before a single element is touched.
3166
3955
  if (index && chosen && isRuleSilent(rule, chosen, index))
3167
3956
  continue;
3168
- let raised = RULE_FAMILIES[rule.family](rule, elements, incremental);
3957
+ // What this rule must JUDGE. `null` — a surface scope, a touched frame, no
3958
+ // closure at all — is the full pass every caller had before PF5.4.
3959
+ const subjects = closure?.subjectsOf(rule) ?? null;
3960
+ let raised = RULE_FAMILIES[rule.family](rule, elements, subjects === null ? context : { ...context, subjects });
3169
3961
  if (index && chosen && raised.length > 0) {
3170
3962
  raised = applyProfiles(rule, raised, chosen, index);
3171
3963
  }
3172
- if (raised.length === 0)
3173
- continue;
3174
3964
  // Exceptions are read LAST, and independently of the profile: an
3175
3965
  // arbitration a user made is theirs, and changing the level of requirement
3176
3966
  // never revokes one (PF9.3).
3177
- applyExceptions(rule, raised, elements);
3967
+ if (raised.length > 0)
3968
+ applyExceptions(rule, raised, elements);
3969
+ // What this rule did NOT judge keeps the verdict it already had — profile
3970
+ // and exception included, since `previous` is a finished answer and both
3971
+ // passes were applied to it when it was made. A change to either marks its
3972
+ // element dirty ({@link VERDICT_PROPS}), so nothing carried can be stale.
3973
+ if (subjects !== null) {
3974
+ const carried = closure.carried(rule, subjects);
3975
+ if (carried.length > 0)
3976
+ violations.push(...carried);
3977
+ }
3178
3978
  violations.push(...raised);
3179
3979
  }
3180
3980
  return violations;
3181
3981
  }
3182
- /** The registered rules a check-up runs — and nothing else ever evaluates. */
3183
- export function onDemandRules(rules) {
3184
- return rules.filter(rule => !isRealtime(rule));
3982
+ /**
3983
+ * The registered rules a check-up runs on THIS surface — and nothing else ever
3984
+ * evaluates.
3985
+ *
3986
+ * The same question {@link evaluateCheckup} answers by running them, asked
3987
+ * without running anything: a host listing what a check-up would walk, and the
3988
+ * manager sizing one ({@link ValidationManager.checkupRulesFor}), get exactly
3989
+ * the set that pass will use — which means asking it the same way, against the
3990
+ * levels the surface's own frames have CHOSEN ({@link momentOf}) and not
3991
+ * against the registry in the abstract. Hand it no elements and the framework
3992
+ * defaults govern, which is what an empty board is.
3993
+ */
3994
+ export function checkupRules(rules, elements, profiles = []) {
3995
+ const index = profiles.length > 0 ? indexProfiles(profiles) : null;
3996
+ const chosen = index ? readChosenProfiles(elements) : null;
3997
+ return rules.filter(rule => momentOf(rule, chosen, index) === 'on-demand');
3185
3998
  }
3186
3999
  /**
3187
4000
  * Run the ON-DEMAND rules over the surface (PF5.14). The exact mirror of
@@ -3198,9 +4011,33 @@ export function onDemandRules(rules) {
3198
4011
  * never reach the timeline, the bracket or the badge. A framework declares them
3199
4012
  * `audit` on top of that, which is the severity for "collected, invisible to the
3200
4013
  * drawing user" — belt and braces, and the braces are structural.
4014
+ *
4015
+ * ## A SEED, i.e. "audit this much of the board" (PF7.9)
4016
+ *
4017
+ * `seed` is a set of element ids the caller wants judged — the host's selection,
4018
+ * typically. It reaches the same pipeline as any other incremental pass, with
4019
+ * `previous` deliberately EMPTY: a seed is not a change to build on, it is a
4020
+ * question about a part of the board, so nothing is carried over and the answer
4021
+ * is exactly the fresh verdicts on the closure of the seed ({@link dirtyClosure}).
4022
+ * `wasIn` is the CURRENT attribution, which is the honest memory here: nothing
4023
+ * moved between the two, so where an element is and where it was are the same
4024
+ * fact.
4025
+ *
4026
+ * The contract, for a seed `S` and against the same call without one:
4027
+ *
4028
+ * - `seeded ⊆ full` — a seeded run never invents a verdict; and
4029
+ * - `seeded ∩ touching(S) = full ∩ touching(S)`, where `touching(S)` is the
4030
+ * findings naming an element or a frame of `S` — a seeded run never MISSES one
4031
+ * that is about the seed.
4032
+ *
4033
+ * Everything between the two is what the caller chose not to ask about. A seed
4034
+ * containing a frame asks about that whole map: the frame guards in
4035
+ * {@link dirtyClosure} send every rule measured against it through a full pass.
3201
4036
  */
3202
- export function evaluateCheckup(rules, elements, profiles = []) {
3203
- return runRules(rules, elements, profiles, 'on-demand');
4037
+ export function evaluateCheckup(rules, elements, profiles = [], seed) {
4038
+ return runRules(rules, elements, profiles, 'on-demand', seed === undefined
4039
+ ? undefined
4040
+ : { dirty: seed, previous: [], wasIn: frameMembership(rules, elements) });
3204
4041
  }
3205
4042
  /**
3206
4043
  * How long a check-up may hold the thread before yielding it back.
@@ -3464,6 +4301,11 @@ export const VERDICT_PROPS = [
3464
4301
  * it would hand back precisely what declaring the second moment bought — and a
3465
4302
  * framework that wants its naming checked while the user types says so by
3466
4303
  * declaring the rule real-time, which is the one place that decision belongs.
4304
+ *
4305
+ * The DECLARED moment, like {@link backgroundElementIds} and for the same
4306
+ * reason: this set is computed ONCE per manager, before any surface exists to
4307
+ * read a level from, and watching a prop nothing turns out to need costs a
4308
+ * debounced pass that finds nothing — never a wrong verdict.
3467
4309
  */
3468
4310
  export function verdictPropsOf(rules) {
3469
4311
  const props = new Set(VERDICT_PROPS);
@@ -3632,6 +4474,17 @@ export class ValidationManager extends InteractivityExtension {
3632
4474
  * luck of no shipped profile using `'off'` yet.
3633
4475
  */
3634
4476
  this._backgrounds = new Set();
4477
+ /**
4478
+ * Where every element WAS, frame by frame, at the last evaluation — the other
4479
+ * half of the memory {@link _backgrounds} is, and for the same kind of reason.
4480
+ *
4481
+ * A `'frame'`-scope verdict is about a frame's whole content, so an element
4482
+ * that has just LEFT one changes the answer there as much as it does where it
4483
+ * landed — and the surface, which only knows where things are now, cannot say
4484
+ * where anything was. See {@link frameMembership}: empty, and free, unless a
4485
+ * frame-scope rule is registered.
4486
+ */
4487
+ this._membership = new Map();
3635
4488
  this._rules = null;
3636
4489
  this._verdictProps = null;
3637
4490
  this._nudges = null;
@@ -3726,6 +4579,7 @@ export class ValidationManager extends InteractivityExtension {
3726
4579
  this._dirty.clear();
3727
4580
  this._evaluated = false;
3728
4581
  this._backgrounds = new Set();
4582
+ this._membership = new Map();
3729
4583
  this.timeline.clear();
3730
4584
  // Supersede any check-up still yielding between two rules, and forget the
3731
4585
  // last one: both are session state, and a stale answer outliving the
@@ -3778,19 +4632,35 @@ export class ValidationManager extends InteractivityExtension {
3778
4632
  /**
3779
4633
  * Evaluate now. Exposed so a host (and the bench) can drive it directly.
3780
4634
  *
3781
- * `incremental` is the DEBOUNCED path's privilege and nobody else's: it is
3782
- * the only caller that knows exactly which elements moved since the last
3783
- * verdict. Everything else — a gesture that must land on the spot, a surface
3784
- * arriving, a host asking — pays for a full pass, because a full pass is
3785
- * always right and a wrong dirty set is a board that lies.
4635
+ * An incremental pass is the privilege of WHOEVER KNOWS WHAT CHANGED — the
4636
+ * debounced path by default, and since PF7.9 any caller willing to say so:
4637
+ * `true` runs the debounced path's own accumulated dirty set, an object seeds
4638
+ * the same pipeline with the caller's, `false` (the default) pays for a full
4639
+ * pass. Nothing about the guarantee is relaxed by opening it: a full pass is
4640
+ * always right, and a wrong seed is still a board that lies — so a caller that
4641
+ * is not certain hands in nothing rather than a guess.
4642
+ *
4643
+ * The seed may contain FRAME ids, and that is a request rather than a mistake:
4644
+ * a frame among the dirty ids means "re-judge this board", and it lands on the
4645
+ * {@link _backgrounds} guard below, which is exactly a full pass.
4646
+ *
4647
+ * A pending debounce is never lost: what it had accumulated is folded INTO the
4648
+ * seed and its timer cleared, so one pass answers both questions.
3786
4649
  */
3787
- evaluate(incremental = false) {
4650
+ evaluate(seed = false) {
3788
4651
  const rules = this._activeRules;
3789
4652
  const surface = this.gfx.surface;
3790
4653
  // Whatever happens below, what accumulated is now accounted for: a dirty
3791
4654
  // set left behind would be replayed against a later, unrelated snapshot.
3792
4655
  const dirty = this._dirty;
3793
4656
  this._dirty = new Set();
4657
+ if (typeof seed === 'object') {
4658
+ for (const id of seed.dirty)
4659
+ dirty.add(id);
4660
+ if (this._pending)
4661
+ clearTimeout(this._pending);
4662
+ this._pending = null;
4663
+ }
3794
4664
  if (rules.length === 0 || !surface)
3795
4665
  return;
3796
4666
  const previous = this.violations$.peek();
@@ -3798,11 +4668,12 @@ export class ValidationManager extends InteractivityExtension {
3798
4668
  // pair-wise re-test would reach — including, when its profile silenced the
3799
4669
  // rule, findings that were never recorded for the family to find.
3800
4670
  const frameTouched = [...dirty].some(id => this._backgrounds.has(id));
3801
- const violations = evaluateRules(rules, surface.elementModels, this._activeProfiles, incremental && this._evaluated && dirty.size > 0 && !frameTouched
3802
- ? { dirty, previous }
4671
+ const violations = evaluateRules(rules, surface.elementModels, this._activeProfiles, seed !== false && this._evaluated && dirty.size > 0 && !frameTouched
4672
+ ? { dirty, previous, wasIn: this._membership }
3803
4673
  : undefined);
3804
4674
  this._evaluated = true;
3805
4675
  this._backgrounds = backgroundElementIds(rules, surface.elementModels);
4676
+ this._membership = frameMembership(rules, surface.elementModels);
3806
4677
  // Stay silent when nothing changed: `violations$` is the seam a host panel
3807
4678
  // subscribes to, and a clean board must not wake it on every debounce tick.
3808
4679
  if (violations.length === 0 && this.violations$.peek().length === 0)
@@ -3874,12 +4745,15 @@ export class ValidationManager extends InteractivityExtension {
3874
4745
  .filter(nudge => frameworks.has(nudge.framework))
3875
4746
  .sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
3876
4747
  }
3877
- /** The on-demand rules a check-up on `element` would walk (PF5.14). */
4748
+ /**
4749
+ * The on-demand rules a check-up on `element` would walk (PF5.14) — under the
4750
+ * levels the surface's frames are on right now (PF7.6).
4751
+ */
3878
4752
  checkupRulesFor(element) {
3879
4753
  const frameworks = this.frameworksOf(element);
3880
4754
  if (frameworks.size === 0)
3881
4755
  return [];
3882
- return onDemandRules(this._activeRules).filter(rule => frameworks.has(rule.framework));
4756
+ return checkupRules(this._activeRules, this.gfx.surface?.elementModels ?? [], this._activeProfiles).filter(rule => frameworks.has(rule.framework));
3883
4757
  }
3884
4758
  /**
3885
4759
  * Whether `element` has a Map quality panel to open at all: a framework of
@@ -3969,10 +4843,19 @@ export class ValidationManager extends InteractivityExtension {
3969
4843
  * remounted. So the run is finished with {@link CheckupRun.error} and whatever
3970
4844
  * it had managed to collect.
3971
4845
  *
4846
+ * ## A SEED, i.e. "audit this much of it" (PF7.9)
4847
+ *
4848
+ * `seed` narrows the QUESTION, never the answer's honesty: the run judges the
4849
+ * closure of those ids ({@link evaluateCheckup}) and carries nothing, so what
4850
+ * comes back is `full ∩ about-the-seed` — the same verdicts a whole-board
4851
+ * check-up would have produced there, and none of the ones it would have
4852
+ * produced elsewhere. A host that has a selection can therefore ask about it;
4853
+ * one that has not passes nothing and gets the whole map, as before.
4854
+ *
3972
4855
  * @returns the finished run, or `null` if it was superseded or there was
3973
4856
  * nothing to run.
3974
4857
  */
3975
- async runCheckup(element) {
4858
+ async runCheckup(element, seed) {
3976
4859
  const rules = this.checkupRulesFor(element);
3977
4860
  const generation = ++this._checkupGeneration;
3978
4861
  const backgroundId = element.id;
@@ -3993,7 +4876,7 @@ export class ValidationManager extends InteractivityExtension {
3993
4876
  if (!surface || generation !== this._checkupGeneration)
3994
4877
  return null;
3995
4878
  try {
3996
- for (const remark of evaluateCheckup([rules[i]], surface.elementModels, this._activeProfiles)) {
4879
+ for (const remark of evaluateCheckup([rules[i]], surface.elementModels, this._activeProfiles, seed)) {
3997
4880
  // This map's remarks, and only this map's. A family that measures
3998
4881
  // against no frame records no `backgroundId` and is dropped: it has
3999
4882
  // said nothing about the instance the user is looking at.