@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.
- package/dist/_pkgs/affine-block-frame/frame-manager.d.ts +31 -0
- package/dist/_pkgs/affine-block-frame/frame-manager.js +62 -2
- package/dist/_pkgs/affine-block-frame/frame-tool.js +6 -2
- package/dist/_pkgs/affine-block-surface/extensions/audit.js +12 -34
- package/dist/_pkgs/affine-block-surface/extensions/reading-toolbar.js +12 -12
- package/dist/_pkgs/affine-block-surface/extensions/reading.js +10 -28
- package/dist/_pkgs/affine-block-surface/extensions/validation.d.ts +321 -16
- package/dist/_pkgs/affine-block-surface/extensions/validation.js +945 -62
- package/dist/_pkgs/affine-block-surface/framework-background/facts.d.ts +24 -0
- package/dist/_pkgs/affine-block-surface/framework-background/facts.js +31 -0
- package/dist/_pkgs/affine-block-surface/framework-background/index.d.ts +1 -0
- package/dist/_pkgs/affine-block-surface/framework-background/index.js +1 -0
- package/dist/_pkgs/affine-block-surface/framework-background/stacking.d.ts +131 -0
- package/dist/_pkgs/affine-block-surface/framework-background/stacking.js +184 -0
- package/dist/_pkgs/affine-block-surface/utils/sort.d.ts +15 -1
- package/dist/_pkgs/affine-block-surface/utils/sort.js +30 -31
- package/dist/_pkgs/affine-block-surface/view.js +5 -0
- package/dist/_pkgs/affine-components/highlight-dropdown-menu/highlight-duotone-icon.js +8 -8
- package/dist/_pkgs/affine-components/highlight-dropdown-menu/text-duotone-icon.js +11 -11
- package/dist/_pkgs/affine-components/icons/text.js +426 -426
- package/dist/_pkgs/affine-gfx-group/element-renderer/utils.js +3 -1
- package/dist/_pkgs/affine-gfx-template/index.d.ts +1 -0
- package/dist/_pkgs/affine-gfx-template/index.js +1 -0
- package/dist/_pkgs/affine-gfx-template/make-snapshot.d.ts +16 -2
- package/dist/_pkgs/affine-gfx-template/make-snapshot.js +43 -4
- package/dist/_pkgs/affine-gfx-template/services/template-middlewares.js +1 -0
- package/dist/_pkgs/affine-gfx-template/services/template.d.ts +6 -0
- package/dist/_pkgs/affine-gfx-template/services/template.js +6 -0
- package/dist/_pkgs/affine-gfx-template/snapshot-from-action.d.ts +20 -0
- package/dist/_pkgs/affine-gfx-template/snapshot-from-action.js +205 -0
- package/dist/_pkgs/affine-gfx-template/toolbar/template-panel.js +4 -0
- package/dist/_pkgs/affine-gfx-template/toolbar/template-type.d.ts +13 -0
- package/dist/_pkgs/affine-inline-link/link-node/configs/toolbar.d.ts +11 -17
- package/dist/_pkgs/affine-inline-link/link-node/configs/toolbar.js +55 -14
- package/dist/_pkgs/affine-model/elements/cynefin/cynefin.d.ts +9 -20
- package/dist/_pkgs/affine-model/elements/cynefin/cynefin.js +7 -28
- package/dist/_pkgs/affine-model/elements/edgy/board.d.ts +9 -21
- package/dist/_pkgs/affine-model/elements/edgy/board.js +8 -29
- package/dist/_pkgs/affine-model/elements/edgy/facets.d.ts +14 -28
- package/dist/_pkgs/affine-model/elements/edgy/facets.js +12 -36
- package/dist/_pkgs/affine-model/elements/estuarine/estuarine.d.ts +9 -19
- package/dist/_pkgs/affine-model/elements/estuarine/estuarine.js +7 -27
- package/dist/_pkgs/affine-shared/services/link-preview-service/link-preview-service.d.ts +28 -0
- package/dist/_pkgs/affine-shared/services/link-preview-service/link-preview-service.js +20 -3
- package/dist/_pkgs/affine-widget-edgeless-auto-connect/index.js +124 -124
- package/dist/_pkgs/affine-widget-edgeless-zoom-toolbar/index.js +26 -26
- package/dist/_pkgs/affine-widget-keyboard-toolbar/icons.js +98 -98
- package/dist/_pkgs/affine-widget-page-dragging-area/index.js +10 -10
- package/dist/_pkgs/affine-widget-viewport-overlay/index.js +13 -13
- package/dist/_pkgs/data-view/core/group-by/renderer/boolean-group.js +11 -11
- package/dist/_pkgs/data-view/core/utils/menu-title.js +17 -17
- package/dist/_pkgs/data-view/view-presets/table/pc-virtual/group/top/group-title.js +18 -18
- 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
|
-
*
|
|
1202
|
-
* declared roles, never the whole surface
|
|
1203
|
-
*
|
|
1204
|
-
*
|
|
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
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
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
|
|
1303
|
-
*
|
|
1304
|
-
*
|
|
1305
|
-
*
|
|
1306
|
-
*
|
|
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
|
-
*
|
|
1879
|
-
*
|
|
1880
|
-
*
|
|
1881
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
3161
|
-
//
|
|
3162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
3183
|
-
|
|
3184
|
-
|
|
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
|
-
*
|
|
3782
|
-
*
|
|
3783
|
-
*
|
|
3784
|
-
*
|
|
3785
|
-
*
|
|
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(
|
|
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,
|
|
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
|
-
/**
|
|
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
|
|
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.
|