From bb8d5587a0984e057b9c815f7b2870ef277a20ee Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Thu, 20 Aug 2026 10:10:37 +0300 Subject: [PATCH 1/2] fix(viewer): group IDS panel results by requirement, not just entity (#2933) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-app IDS results panel had the same defect confirmed in the concurrent HTML export rework: requirement results were only visible by expanding one entity at a time, so a user could not see, per requirement, how many checks passed/failed or which elements failed without clicking into every entity. Add apps/viewer/src/hooks/ids/idsRequirementGrouping.ts — pure helpers that re-slice a specification's entityResults by requirement (keyed on requirement.id, stable across entities within a spec since the validator reuses the same IDSRequirement object per requirement) and compute a check-level pass rate (one check = one entity x one requirement). not_applicable is excluded from both numerator and denominator of every rate, matching how the validator itself treats applicableCount/passedCount/failedCount. IDSPanel's SpecificationCard now renders a "Requirement Breakdown" section (facet type, description, pass/fail/n-a counts, failing elements with type/name/GlobalId/reason, click-to-select) above the existing per-entity list, and a check-level rate line next to the existing entity-level PassRateBar — the two legitimately differ whenever one entity fails more than one requirement. This module is standalone, not shared with idsExportService.ts (owned by a concurrent HTML/JSON export rework) — no shared grouping helper existed between the two before this change. --- .../viewer/src/components/viewer/IDSPanel.tsx | 124 ++++++++++++ .../hooks/ids/idsRequirementGrouping.test.ts | 178 ++++++++++++++++++ .../src/hooks/ids/idsRequirementGrouping.ts | 165 ++++++++++++++++ 3 files changed, 467 insertions(+) create mode 100644 apps/viewer/src/hooks/ids/idsRequirementGrouping.test.ts create mode 100644 apps/viewer/src/hooks/ids/idsRequirementGrouping.ts diff --git a/apps/viewer/src/components/viewer/IDSPanel.tsx b/apps/viewer/src/components/viewer/IDSPanel.tsx index 044345a59c..94c694ea87 100644 --- a/apps/viewer/src/components/viewer/IDSPanel.tsx +++ b/apps/viewer/src/components/viewer/IDSPanel.tsx @@ -73,6 +73,11 @@ import type { IDSEntityResult, IDSRequirementResult, } from '@ifc-lite/ids'; +import { + groupRequirementResults, + computeCheckStats, + type RequirementGroup, +} from '@/hooks/ids/idsRequirementGrouping'; import { cn } from '@/lib/utils'; import { tourAnchor, TOUR_ANCHORS } from '@/lib/tours/anchors'; import { useViewerStore } from '@/store'; @@ -169,6 +174,27 @@ function SpecificationCard({ ); }, [result.entityResults, filterMode]); + // Regroup this specification's entity results by requirement ("check") + // rather than by entity. A specification can carry several requirements + // (fire rating, certificate ref, width, ...) — grouping first (before any + // status filtering) keeps the per-requirement counts aligned across + // entities; see idsRequirementGrouping.ts for why that ordering matters. + const requirementGroups = useMemo( + () => groupRequirementResults(result.entityResults), + [result.entityResults] + ); + const checkStats = useMemo( + () => computeCheckStats(result.entityResults), + [result.entityResults] + ); + const filteredRequirementGroups = useMemo(() => { + if (filterMode === 'all') return requirementGroups; + return requirementGroups.filter((g) => + filterMode === 'failed' ? g.failedCount > 0 : g.passedCount > 0 + ); + }, [requirementGroups, filterMode]); + const applicableChecks = checkStats.passedChecks + checkStats.failedChecks; + return (
+ {/* Check-level rate: one entity can fail several requirements + at once, so this legitimately differs from (and is <=) the + entity-level rate above — both matter and are shown + separately rather than picking one. */} + {applicableChecks > 0 && ( +
+ {checkStats.passedChecks}/{applicableChecks} checks passed ({checkStats.checkPassRate}%) + {requirementGroups.length > 1 && ` across ${requirementGroups.length} requirements`} +
+ )} + {/* Requirement Breakdown */} + + +
+ {filteredRequirementGroups.length === 0 ? ( +
+ No {filterMode === 'failed' ? 'failed' : filterMode === 'passed' ? 'passed' : ''} requirements +
+ ) : ( + filteredRequirementGroups.map((group) => ( + + )) + )} +
+
+ {/* Entity Results */} +
By entity
{filteredEntities.length === 0 ? (
@@ -340,6 +393,77 @@ function RequirementResultRow({ result }: RequirementResultRowProps) { ); } +// ============================================================================ +// Requirement Group Row Component +// ============================================================================ + +interface RequirementGroupRowProps { + group: RequirementGroup; + onEntityClick: (modelId: string, expressId: number) => void; +} + +function RequirementGroupRow({ group, onEntityClick }: RequirementGroupRowProps) { + const [showFailures, setShowFailures] = useState(false); + const hasFailures = group.failingEntities.length > 0; + const status: 'pass' | 'fail' | 'not_applicable' = + group.failedCount > 0 ? 'fail' : group.passedCount > 0 ? 'pass' : 'not_applicable'; + + return ( +
+ + {showFailures && hasFailures && ( +
+ {group.failingEntities.map((entity) => ( + + ))} +
+ )} +
+ ); +} + // ============================================================================ // Report Export Split Button // ============================================================================ diff --git a/apps/viewer/src/hooks/ids/idsRequirementGrouping.test.ts b/apps/viewer/src/hooks/ids/idsRequirementGrouping.test.ts new file mode 100644 index 0000000000..9798c83a13 --- /dev/null +++ b/apps/viewer/src/hooks/ids/idsRequirementGrouping.test.ts @@ -0,0 +1,178 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import type { IDSEntityResult, IDSRequirementResult, IDSRequirement } from '@ifc-lite/ids'; +import { groupRequirementResults, computeCheckStats } from './idsRequirementGrouping.js'; + +/** + * Contract tests for the IDS panel's requirement-grouping helpers. + * + * The IDSPanel used to only expose `requirementResults` inside a + * per-entity expander — the same defect the HTML export had. These + * helpers re-slice a specification's entity results by requirement so a + * user can see, per requirement, how many checks passed/failed and which + * elements failed, without drilling into every entity individually. + */ + +function makeRequirement(id: string): IDSRequirement { + return { + id, + facet: { type: 'attribute', name: { simpleValue: 'Name' } } as unknown as IDSRequirement['facet'], + optionality: 'required', + }; +} + +const fireRating = makeRequirement('req-0'); +const certificateRef = makeRequirement('req-1'); +const width = makeRequirement('req-2'); + +function reqResult( + requirement: IDSRequirement, + status: IDSRequirementResult['status'], + overrides: Partial = {} +): IDSRequirementResult { + return { + requirement, + status, + facetType: 'attribute', + checkedDescription: `Checks ${requirement.id}`, + ...overrides, + }; +} + +function entity( + expressId: number, + requirementResults: IDSRequirementResult[], + overrides: Partial = {} +): IDSEntityResult { + return { + expressId, + modelId: 'model-1', + entityType: 'IfcDoor', + entityName: `Door ${expressId}`, + globalId: `GID-${expressId}`, + passed: requirementResults.every((r) => r.status !== 'fail'), + requirementResults, + ...overrides, + }; +} + +describe('groupRequirementResults', () => { + it('groups per-entity requirement results by requirement, not by entity', () => { + // 3 doors, each checked against 2 requirements (fire rating, width). + const entities: IDSEntityResult[] = [ + entity(1, [reqResult(fireRating, 'pass'), reqResult(width, 'pass')]), + entity(2, [reqResult(fireRating, 'fail', { failureReason: 'Missing FireRating' }), reqResult(width, 'pass')]), + entity(3, [reqResult(fireRating, 'pass'), reqResult(width, 'fail', { failureReason: 'Width too small' })]), + ]; + + const groups = groupRequirementResults(entities); + + assert.strictEqual(groups.length, 2, 'one group per requirement, not per entity'); + const byKey = new Map(groups.map((g) => [g.key, g])); + + const fireGroup = byKey.get('req-0')!; + assert.strictEqual(fireGroup.passedCount, 2); + assert.strictEqual(fireGroup.failedCount, 1); + assert.strictEqual(fireGroup.failingEntities.length, 1); + assert.strictEqual(fireGroup.failingEntities[0].expressId, 2); + assert.strictEqual(fireGroup.failingEntities[0].failureReason, 'Missing FireRating'); + + const widthGroup = byKey.get('req-2')!; + assert.strictEqual(widthGroup.passedCount, 2); + assert.strictEqual(widthGroup.failedCount, 1); + assert.strictEqual(widthGroup.failingEntities[0].expressId, 3); + }); + + it('does not count not_applicable as a pass, in either the group counts or its rate', () => { + // certificateRef is only applicable to 2 of 3 doors. + const entities: IDSEntityResult[] = [ + entity(1, [reqResult(certificateRef, 'not_applicable')]), + entity(2, [reqResult(certificateRef, 'pass')]), + entity(3, [reqResult(certificateRef, 'fail', { failureReason: 'No certificate' })]), + ]; + + const [group] = groupRequirementResults(entities); + + assert.strictEqual(group.notApplicableCount, 1); + assert.strictEqual(group.passedCount, 1); + assert.strictEqual(group.failedCount, 1); + // Rate is passed / (passed + failed) = 1/2 = 50, NOT 1/3 (which would + // silently treat not_applicable as a passing check) and NOT 2/3 + // (which would silently treat it as a failing one). + assert.strictEqual(group.passRate, 50); + }); + + it('grouping is order-independent of pre-filtering: not_applicable entities still register in the right group', () => { + // Regression guard for the "grouping trap": filtering not_applicable + // OUT before grouping would misalign per-requirement counts. Here we + // deliberately interleave entities whose FIRST requirement result is + // not_applicable to prove the grouping keys off requirement id, not + // array position filtered by status. + const entities: IDSEntityResult[] = [ + entity(1, [reqResult(certificateRef, 'not_applicable'), reqResult(width, 'pass')]), + entity(2, [reqResult(certificateRef, 'pass'), reqResult(width, 'fail', { failureReason: 'too narrow' })]), + ]; + + const groups = groupRequirementResults(entities); + assert.strictEqual(groups.length, 2); + + const widthGroup = groups.find((g) => g.key === 'req-2')!; + assert.strictEqual(widthGroup.passedCount, 1); + assert.strictEqual(widthGroup.failedCount, 1); + assert.strictEqual(widthGroup.failingEntities[0].expressId, 2); + }); +}); + +describe('computeCheckStats', () => { + it('check-level pass rate legitimately differs from (and is <=) an entity-level rate', () => { + // 3 doors x 3 requirements = 9 checks. Only 1 check fails, but it's + // spread across a single entity, so entity-level (2/3 = 67%) and + // check-level (8/9 = 88%) intentionally disagree. + const entities: IDSEntityResult[] = [ + entity(1, [reqResult(fireRating, 'pass'), reqResult(certificateRef, 'pass'), reqResult(width, 'pass')]), + entity(2, [reqResult(fireRating, 'pass'), reqResult(certificateRef, 'pass'), reqResult(width, 'pass')]), + entity(3, [ + reqResult(fireRating, 'fail', { failureReason: 'Missing FireRating' }), + reqResult(certificateRef, 'pass'), + reqResult(width, 'pass'), + ]), + ]; + + const stats = computeCheckStats(entities); + assert.strictEqual(stats.passedChecks, 8); + assert.strictEqual(stats.failedChecks, 1); + assert.strictEqual(stats.checkPassRate, 88); + + // Entity-level rate computed the way the validator computes + // specification.passRate (passed entities / total entities, floored). + const passedEntities = entities.filter((e) => e.passed).length; + const entityPassRate = Math.floor((passedEntities / entities.length) * 100); + assert.strictEqual(entityPassRate, 66); + assert.notStrictEqual(stats.checkPassRate, entityPassRate); + }); + + it('excludes not_applicable checks from both the numerator and denominator', () => { + const entities: IDSEntityResult[] = [ + entity(1, [reqResult(certificateRef, 'not_applicable')]), + entity(2, [reqResult(certificateRef, 'not_applicable')]), + entity(3, [reqResult(certificateRef, 'pass')]), + ]; + + const stats = computeCheckStats(entities); + assert.strictEqual(stats.notApplicableChecks, 2); + assert.strictEqual(stats.passedChecks, 1); + assert.strictEqual(stats.failedChecks, 0); + // 1/1 applicable check passed = 100%, not 1/3 (33%). + assert.strictEqual(stats.checkPassRate, 100); + }); + + it('defaults to a 100% rate when there are no applicable checks at all', () => { + const entities: IDSEntityResult[] = [entity(1, [reqResult(certificateRef, 'not_applicable')])]; + const stats = computeCheckStats(entities); + assert.strictEqual(stats.checkPassRate, 100); + }); +}); diff --git a/apps/viewer/src/hooks/ids/idsRequirementGrouping.ts b/apps/viewer/src/hooks/ids/idsRequirementGrouping.ts new file mode 100644 index 0000000000..b312ce3119 --- /dev/null +++ b/apps/viewer/src/hooks/ids/idsRequirementGrouping.ts @@ -0,0 +1,165 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +/** + * IDS Requirement Grouping — pure helpers to re-slice a specification's + * per-entity results by requirement ("check") instead of by entity. + * + * An `IDSSpecificationResult` carries `entityResults: IDSEntityResult[]`, + * each with a `requirementResults: IDSRequirementResult[]` produced by + * iterating the SAME `spec.requirements` array for every entity + * (`validateEntityRequirements` in `packages/ids/src/validation/validator.ts`). + * The validator also reuses that same `IDSRequirement` object (not a + * per-entity clone) for every entity it checks, so + * `requirementResult.requirement.id` (assigned once at parse time as + * `req-${index}`, see `packages/ids/src/parser/xml-parser.ts`) is a + * stable key to group by ACROSS entities within one specification. + * It is only unique within a specification, not across specifications — + * grouping must stay scoped to one `IDSSpecificationResult` at a time, + * which is how every caller here uses it. + * + * `not_applicable` is excluded from both the numerator and the + * denominator of every rate computed here, mirroring how the validator + * itself treats specification-level `applicableCount` / `passedCount` / + * `failedCount` (`packages/ids/src/validation/validator.ts`): entities + * are only ever tallied as passed or failed, never as a third bucket, + * and the rate is `Math.floor(passed / total * 100)`. + * + * This module is standalone and not shared with + * `apps/viewer/src/hooks/ids/idsExportService.ts` (the HTML/JSON export, + * owned by a concurrent change) — it exists so the in-app panel can group + * by requirement without touching that file. + */ + +import type { + IDSEntityResult, + IDSRequirementResult, +} from '@ifc-lite/ids'; + +/** One failing entity, flattened with the failure detail for a single requirement. */ +export interface RequirementFailingEntity { + modelId: string; + expressId: number; + entityType: string; + entityName?: string; + globalId?: string; + failureReason?: string; + actualValue?: string; + expectedValue?: string; +} + +/** All entity-level results rolled up for one requirement within a specification. */ +export interface RequirementGroup { + /** `requirement.id`, stable across entities within one specification. */ + key: string; + requirement: IDSRequirementResult['requirement']; + facetType: IDSRequirementResult['facetType']; + checkedDescription: string; + passedCount: number; + failedCount: number; + notApplicableCount: number; + /** Applicable checks only (excludes not_applicable), floored like the validator. */ + passRate: number; + failingEntities: RequirementFailingEntity[]; +} + +/** Check-level rollup: one check = one entity x one requirement. */ +export interface CheckStats { + totalChecks: number; + passedChecks: number; + failedChecks: number; + notApplicableChecks: number; + /** passedChecks / (passedChecks + failedChecks), floored; 100 if no applicable checks. */ + checkPassRate: number; +} + +/** + * Group a specification's entity results by requirement. Entities are + * iterated first and never pre-filtered by status — filtering before + * grouping would silently drop `not_applicable` results and break the + * per-requirement alignment the counts depend on. + */ +export function groupRequirementResults( + entityResults: readonly IDSEntityResult[] +): RequirementGroup[] { + const groups = new Map(); + + for (const entity of entityResults) { + for (const reqResult of entity.requirementResults) { + const key = reqResult.requirement.id; + let group = groups.get(key); + if (!group) { + group = { + key, + requirement: reqResult.requirement, + facetType: reqResult.facetType, + checkedDescription: reqResult.checkedDescription, + passedCount: 0, + failedCount: 0, + notApplicableCount: 0, + passRate: 0, + failingEntities: [], + }; + groups.set(key, group); + } + + if (reqResult.status === 'pass') { + group.passedCount++; + } else if (reqResult.status === 'fail') { + group.failedCount++; + group.failingEntities.push({ + modelId: entity.modelId, + expressId: entity.expressId, + entityType: entity.entityType, + entityName: entity.entityName, + globalId: entity.globalId, + failureReason: reqResult.failureReason, + actualValue: reqResult.actualValue, + expectedValue: reqResult.expectedValue, + }); + } else { + group.notApplicableCount++; + } + } + } + + for (const group of groups.values()) { + const applicable = group.passedCount + group.failedCount; + group.passRate = applicable > 0 ? Math.floor((group.passedCount / applicable) * 100) : 100; + } + + // Preserve first-seen order, which follows `spec.requirements` order + // since every entity iterates that same array in the validator. + return Array.from(groups.values()); +} + +/** + * Roll every entity's requirement checks in a specification into a single + * check-level pass rate — distinct from (and always <=) the specification's + * own entity-level `passRate`, since one entity can fail several checks. + */ +export function computeCheckStats(entityResults: readonly IDSEntityResult[]): CheckStats { + let passedChecks = 0; + let failedChecks = 0; + let notApplicableChecks = 0; + + for (const entity of entityResults) { + for (const reqResult of entity.requirementResults) { + if (reqResult.status === 'pass') passedChecks++; + else if (reqResult.status === 'fail') failedChecks++; + else notApplicableChecks++; + } + } + + const applicableChecks = passedChecks + failedChecks; + const checkPassRate = applicableChecks > 0 ? Math.floor((passedChecks / applicableChecks) * 100) : 100; + + return { + totalChecks: passedChecks + failedChecks + notApplicableChecks, + passedChecks, + failedChecks, + notApplicableChecks, + checkPassRate, + }; +} From 9f45648d3ab66208f109eb78f2b07d201f642950 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Fri, 21 Aug 2026 17:07:24 +0300 Subject: [PATCH 2/2] fix(viewer): correct the inverted check-vs-entity pass-rate claim `computeCheckStats`'s docblock, the IDSPanel comment beside the rendered figure, and a test title all said the check-level rate is "always <=" the specification's entity-level `passRate`. The relation runs the other way: `spec.passRate` is passing ENTITIES over total entities, and an entity is failed by its FIRST failing requirement while its remaining requirements still count as passes here. Entity A passing 1 of 2 checks and entity B passing both give 50% entity-level against 75% check-level. It is not "always >=" either, so the prose now says "normally": the two rates have different denominators, and an entity whose requirements are all `not_applicable` counts as a passing entity while contributing no applicable check at all. Both directions are now arithmetic rather than prose. Mutation-checked: collapsing a failing entity's checks into failures, and substituting the entity-level formula outright, each turn the new tests red. --- .../viewer/src/components/viewer/IDSPanel.tsx | 8 ++-- .../hooks/ids/idsRequirementGrouping.test.ts | 44 ++++++++++++++++++- .../src/hooks/ids/idsRequirementGrouping.ts | 15 ++++++- 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/apps/viewer/src/components/viewer/IDSPanel.tsx b/apps/viewer/src/components/viewer/IDSPanel.tsx index 94c694ea87..82f1a0b68c 100644 --- a/apps/viewer/src/components/viewer/IDSPanel.tsx +++ b/apps/viewer/src/components/viewer/IDSPanel.tsx @@ -238,10 +238,12 @@ function SpecificationCard({
- {/* Check-level rate: one entity can fail several requirements - at once, so this legitimately differs from (and is <=) the + {/* Check-level rate: an entity is failed by its FIRST failing + requirement while its other requirements still count as + passes here, so this normally reads HIGHER than the entity-level rate above — both matter and are shown - separately rather than picking one. */} + separately rather than picking one. See computeCheckStats + for the denominator caveat. */} {applicableChecks > 0 && (
{checkStats.passedChecks}/{applicableChecks} checks passed ({checkStats.checkPassRate}%) diff --git a/apps/viewer/src/hooks/ids/idsRequirementGrouping.test.ts b/apps/viewer/src/hooks/ids/idsRequirementGrouping.test.ts index 9798c83a13..27d24d3a5e 100644 --- a/apps/viewer/src/hooks/ids/idsRequirementGrouping.test.ts +++ b/apps/viewer/src/hooks/ids/idsRequirementGrouping.test.ts @@ -128,7 +128,7 @@ describe('groupRequirementResults', () => { }); describe('computeCheckStats', () => { - it('check-level pass rate legitimately differs from (and is <=) an entity-level rate', () => { + it('check-level pass rate legitimately differs from an entity-level rate', () => { // 3 doors x 3 requirements = 9 checks. Only 1 check fails, but it's // spread across a single entity, so entity-level (2/3 = 67%) and // check-level (8/9 = 88%) intentionally disagree. @@ -175,4 +175,46 @@ describe('computeCheckStats', () => { const stats = computeCheckStats(entities); assert.strictEqual(stats.checkPassRate, 100); }); + + // The direction of the check-level vs entity-level relation was documented + // backwards ("always <=") until this pinned it. It is prose nobody can run, + // so both directions live here as arithmetic instead. + it('reads HIGHER than the entity-level rate when a failing entity still passes some checks', () => { + const entities: IDSEntityResult[] = [ + // A: fails one of two checks -> a FAILED entity that still passes a check. + entity(1, [reqResult(fireRating, 'pass'), reqResult(width, 'fail')]), + // B: passes both. + entity(2, [reqResult(fireRating, 'pass'), reqResult(width, 'pass')]), + ]; + + const stats = computeCheckStats(entities); + const passedEntities = entities.filter((e) => e.passed).length; + const entityPassRate = Math.floor((passedEntities / entities.length) * 100); + + assert.strictEqual(entityPassRate, 50, 'one of two entities passes'); + assert.strictEqual(stats.checkPassRate, 75, 'three of four applicable checks pass'); + assert.ok( + stats.checkPassRate > entityPassRate, + `check-level ${stats.checkPassRate}% must exceed entity-level ${entityPassRate}%, not fall below it` + ); + }); + + it('can read LOWER than the entity-level rate: an all-not_applicable entity passes but contributes no check', () => { + const entities: IDSEntityResult[] = [ + // A: passes as an entity (nothing failed) but adds zero applicable checks. + entity(1, [reqResult(certificateRef, 'not_applicable')]), + // B: fails its only check. + entity(2, [reqResult(certificateRef, 'fail')]), + ]; + + const stats = computeCheckStats(entities); + const passedEntities = entities.filter((e) => e.passed).length; + const entityPassRate = Math.floor((passedEntities / entities.length) * 100); + + assert.strictEqual(entityPassRate, 50); + assert.strictEqual(stats.checkPassRate, 0, 'zero of one applicable check passes'); + // Hence the docblock says "normally above", never "always": the two rates + // have different denominators, so neither direction holds unconditionally. + assert.ok(stats.checkPassRate < entityPassRate); + }); }); diff --git a/apps/viewer/src/hooks/ids/idsRequirementGrouping.ts b/apps/viewer/src/hooks/ids/idsRequirementGrouping.ts index b312ce3119..fbe7ad2fa1 100644 --- a/apps/viewer/src/hooks/ids/idsRequirementGrouping.ts +++ b/apps/viewer/src/hooks/ids/idsRequirementGrouping.ts @@ -136,8 +136,19 @@ export function groupRequirementResults( /** * Roll every entity's requirement checks in a specification into a single - * check-level pass rate — distinct from (and always <=) the specification's - * own entity-level `passRate`, since one entity can fail several checks. + * check-level pass rate. This is a DIFFERENT measure from the specification's + * own entity-level `passRate` (packages/ids/src/validation/validator.ts), not + * a refinement of it, and the two move in a specific relative direction: an + * entity counts as failed the moment ONE of its checks fails, while each of + * its remaining checks still counts as a pass here. So the check-level rate + * normally sits ABOVE the entity-level one — entity A passing 1 of 2 checks + * and entity B passing both give 50% entity-level against 75% check-level. + * + * "Normally", not "always": the two rates have different denominators. An + * entity whose requirements are ALL `not_applicable` counts as a passing + * entity but contributes no applicable checks at all, so enough of those can + * put the entity-level rate above the check-level one. Both directions are + * pinned in idsRequirementGrouping.test.ts. */ export function computeCheckStats(entityResults: readonly IDSEntityResult[]): CheckStats { let passedChecks = 0;