From 75c812e5d41c613a35d7705d8d19d009676041aa Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 11 Nov 2026 11:02:24 -0500 Subject: [PATCH 1/2] core: a typed verify_scene takes only the usage measurements its type needs With the usage checks on, every verify_scene call took every usage measurement, whatever type it asked for. The circulation measurement (passages, turning circles, pockets) is nearly all of the cost on a large building, so a question about skirting or a blocked door paid for it too. usageIssues now lists its measurements with the finding types each gives, and the usage check passes the type asked for: a usage type takes its own measurement, any other type takes none. A misspelt usage type still takes all of them, so its answer names the types there are. Co-Authored-By: Claude Fable 5.2 --- .../core/src/agent-operations/usage-checks.ts | 21 ++- .../verify-scene-typed.test.ts | 141 ++++++++++++++++++ .../core/src/spatial/usage-field/findings.ts | 78 +++++++--- 3 files changed, 218 insertions(+), 22 deletions(-) create mode 100644 packages/core/src/agent-operations/verify-scene-typed.test.ts diff --git a/packages/core/src/agent-operations/usage-checks.ts b/packages/core/src/agent-operations/usage-checks.ts index 5410bc3a8d..b72239ad92 100644 --- a/packages/core/src/agent-operations/usage-checks.ts +++ b/packages/core/src/agent-operations/usage-checks.ts @@ -1,13 +1,28 @@ -import { type UsageIssue, usageIssues } from '../spatial/usage-field/findings' +import { isUsageType, ROOM_ENTRY_TYPE, ROOM_FOLDED_TYPES } from '../agent-tools/verify-scene-usage' +import { USAGE_TYPES, type UsageIssue, usageIssues } from '../spatial/usage-field/findings' import { sdfChecksOn } from '../spatial/usage-field/flag' import { registerSceneCheck } from './verify-scene' +/** + * The finding types a `type` question is about: itself, or what a room's one entry holds. A usage + * type no measurement gives is a misspelt one, and its answer names the types there are: every + * measurement is taken, as for a question with no type. + */ +function askedTypes(type: unknown): ReadonlySet | undefined { + if (typeof type !== 'string' || type.length === 0) return undefined + if (type === ROOM_ENTRY_TYPE) return new Set(ROOM_FOLDED_TYPES) + return isUsageType(type) && !USAGE_TYPES.has(type) ? undefined : new Set([type]) +} + /** * Passages, turning circles, pockets and wall objects, measured on the real shapes of the scene - * (`spatial/usage-field`). Off unless a host turns usage checks on; off, it adds nothing. + * (`spatial/usage-field`). Off unless a host turns usage checks on; off, it adds nothing. A + * question about one finding type takes the measurements that give it, and none for a type that + * is not a usage finding. */ registerSceneCheck({ name: 'usage', order: 60, - run: (nodes) => (sdfChecksOn() ? (usageIssues(nodes) as UsageIssue[]) : []), + run: (nodes, input) => + sdfChecksOn() ? (usageIssues(nodes, { types: askedTypes(input.type) }) as UsageIssue[]) : [], }) diff --git a/packages/core/src/agent-operations/verify-scene-typed.test.ts b/packages/core/src/agent-operations/verify-scene-typed.test.ts new file mode 100644 index 0000000000..1f4ba0ec94 --- /dev/null +++ b/packages/core/src/agent-operations/verify-scene-typed.test.ts @@ -0,0 +1,141 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { verifySceneModelResult } from '../agent-tools/verify-scene' +import { + type AnyNode, + BuildingNode, + DoorNode, + ItemNode, + LevelNode, + SlabNode, + WallNode, + ZoneNode, +} from '../schema' +import { setSdfChecks } from '../spatial/usage-field/flag' +import './index' +import type { SceneNodes } from './types' +import { verifyScene } from './verify-scene' + +type Pt = [number, number] +type Report = { issues: { type: string }[] } +type Answer = { issueCount: number; issues: unknown[] } + +const wall = (id: string, start: Pt, end: Pt, children: string[] = []) => + WallNode.parse({ id, parentId: 'level_1', start, end, thickness: 0.1, children }) + +const door = (id: string, wallId: string, x: number) => + DoorNode.parse({ + id, + parentId: wallId, + wallId, + position: [x, 1.05, 0], + width: 0.9, + height: 2.1, + frameDepth: 0.07, + }) + +const room = (id: string, name: string, x0: number, x1: number) => + ZoneNode.parse({ + id, + parentId: 'level_1', + name, + polygon: [ + [x0, 0], + [x1, 0], + [x1, 4], + [x0, 4], + ], + }) + +const piece = (id: string, x: number, z: number, width: number, depth: number) => + ItemNode.parse({ + id, + parentId: 'level_1', + position: [x, 0, z], + name: id, + asset: { + id, + name: id, + category: 'storage', + thumbnail: '/t.webp', + src: `/items/${id}/model.glb`, + dimensions: [width, 1.0, depth], + }, + }) + +// A 6 x 4 m shell of two rooms, entered from the street into the west one. Its pieces leave a +// narrow way to the inner door, stand in that door's swing, overlap each other and fence off a +// corner, so the report holds findings of several usage types beside the ordinary ones. +const nodes = Object.fromEntries( + ( + [ + BuildingNode.parse({ id: 'building_1', children: ['level_1'] }), + LevelNode.parse({ id: 'level_1', parentId: 'building_1', level: 0, children: [] }), + SlabNode.parse({ + id: 'slab_1', + parentId: 'level_1', + polygon: [ + [0, 0], + [6, 0], + [6, 4], + [0, 4], + ], + }), + wall('wall_s', [0, 0], [6, 0], ['door_front']), + wall('wall_e', [6, 0], [6, 4]), + wall('wall_n', [6, 4], [0, 4]), + wall('wall_w', [0, 4], [0, 0]), + wall('wall_p', [3, 0], [3, 4], ['door_inner']), + door('door_front', 'wall_s', 1.5), + door('door_inner', 'wall_p', 2), + room('zone_west', 'West room', 0, 3), + room('zone_east', 'East room', 3, 6), + piece('item_a', 1.2, 1.0, 0.4, 1.4), + piece('item_b', 2.0, 1.4, 0.6, 0.4), + piece('item_c', 2.2, 1.5, 0.6, 0.6), + piece('item_d', 3.6, 2.0, 0.5, 0.9), + piece('item_e', 4.6, 2.0, 0.4, 3.6), + ] as AnyNode[] + ).map((node) => [node.id, node]), +) as SceneNodes + +const context = { activeLevelId: 'level_1' } +const report = (input: Record) => verifyScene(nodes, input, context).result +const answer = (from: Record, input: Record) => + verifySceneModelResult(from, input) as Answer + +describe('verify_scene asked for one finding type, with the usage checks on', () => { + beforeAll(() => setSdfChecks(true)) + afterAll(() => setSdfChecks(false)) + + test('answers the findings of that type the whole report holds, for every type', () => { + const whole = report({}) + const found = [...new Set((whole as Report).issues.map((issue) => issue.type))] + expect(found.filter((type) => type.startsWith('usage_')).length).toBeGreaterThan(2) + expect(found.some((type) => !type.startsWith('usage_'))).toBe(true) + const asked = [...found, 'usage_room', 'usage_bed_clearance', 'room_unnamed', 'no_such_type'] + for (const type of asked) { + const typed = answer(report({ type }), { type }) + const before = answer(whole, { type }) + expect({ type, issues: typed.issues }).toEqual({ type, issues: before.issues }) + expect(typed.issueCount).toBe(before.issueCount) + } + }) + + test('a room and a type together answer as the whole report does', () => { + const input = { room: 'West room', type: 'usage_room' } + expect(answer(report(input), input)).toEqual(answer(report({ room: 'West room' }), input)) + }) + + test('a misspelt usage type answers with every type the whole report holds', () => { + const input = { type: 'usage_passage_narow' } + expect(answer(report(input), input)).toEqual(answer(report({}), input)) + }) + + test('the report for a type that is not a usage finding holds no usage findings', () => { + const typed = report({ type: 'door_blocked' }) as Report + expect(typed.issues.filter((issue) => issue.type.startsWith('usage_'))).toEqual([]) + expect((report({}) as Report).issues.some((issue) => issue.type.startsWith('usage_'))).toBe( + true, + ) + }) +}) diff --git a/packages/core/src/spatial/usage-field/findings.ts b/packages/core/src/spatial/usage-field/findings.ts index ded10c145e..3ce0431b3a 100644 --- a/packages/core/src/spatial/usage-field/findings.ts +++ b/packages/core/src/spatial/usage-field/findings.ts @@ -18,7 +18,7 @@ import { type Passage, type RoomMeasure, } from './measure' -import { usageShapes } from './shapes' +import { type UsageShapes, usageShapes } from './shapes' import { doorSwingIssues } from './swings' import type { Nodes } from './walls' @@ -297,15 +297,12 @@ function pocketIssue(nodes: Nodes, room: RoomMeasure, from: string): UsageIssue } } -/** - * The usage findings of a scene: narrow passages (measured from the chosen entrance, one for each - * place that is narrow with the rooms it leads to, and the best other door as a fact), missing turning circles, floor a 0.60 m unit cannot get out of, wall - * objects that stand out into circulation, pieces inside something solid, what stands in front of a - * window or a door, a bed's room, a room's headroom and its ceiling. Every threshold is an indicator, - * not a compliance check. - */ -export function usageIssues(nodes: Nodes, options: MeasureOptions = {}): UsageIssue[] { - const shapes = usageShapes(nodes) +/** The passages, turning circles and pockets of every group of rooms, measured on one clearance field. */ +function circulationIssues( + nodes: Nodes, + shapes: UsageShapes, + options: MeasureOptions, +): UsageIssue[] { const measure = measureUsage(nodes, shapes, options) const issues: UsageIssue[] = [] for (const group of measure.groups) { @@ -343,14 +340,57 @@ export function usageIssues(nodes: Nodes, options: MeasureOptions = {}): UsageIs } } } - issues.push( - ...clashIssues(nodes, shapes), - ...blockedOpeningIssues(nodes, shapes), - ...doorSwingIssues(nodes, shapes), - ...entryIssues(nodes, shapes), - ...doorOverStairIssues(nodes, shapes), - ...fixtureClearanceIssues(nodes, shapes), - ...bedIssues(nodes, shapes), - ) return issues } + +/** + * Each measurement with the finding types it gives, in the order their findings are listed. On a + * building of a few hundred rooms the circulation measurement takes seconds and the others a + * fraction of one, so a question about some types takes the measurements that give them alone. + */ +const MEASUREMENTS: { + types: readonly UsageIssue['type'][] + run: (nodes: Nodes, shapes: UsageShapes, options: MeasureOptions) => UsageIssue[] +}[] = [ + { + types: ['usage_pocket', 'usage_passage_narrow', 'usage_turning_narrow'], + run: circulationIssues, + }, + { types: ['usage_clash'], run: clashIssues }, + { + types: ['usage_opening_blocked'], + run: (nodes, shapes) => blockedOpeningIssues(nodes, shapes), + }, + { types: ['usage_door_swing'], run: doorSwingIssues }, + { types: ['usage_entry_narrow'], run: entryIssues }, + { types: ['usage_door_over_stair'], run: doorOverStairIssues }, + { types: ['usage_fixture_clearance'], run: fixtureClearanceIssues }, + { types: ['usage_bed_clearance'], run: bedIssues }, +] + +/** Every finding type a measurement gives. */ +export const USAGE_TYPES: ReadonlySet = new Set( + MEASUREMENTS.flatMap((measurement) => measurement.types), +) + +export type UsageOptions = MeasureOptions & { + /** The finding types asked for; left out, every type. A measurement's findings come whole. */ + types?: ReadonlySet +} + +/** + * The usage findings of a scene: narrow passages (measured from the chosen entrance, one for each + * place that is narrow with the rooms it leads to, and the best other door as a fact), missing turning circles, floor a 0.60 m unit cannot get out of, wall + * objects that stand out into circulation, pieces inside something solid, what stands in front of a + * window or a door, a bed's room, a room's headroom and its ceiling. Every threshold is an indicator, + * not a compliance check. + */ +export function usageIssues(nodes: Nodes, options: UsageOptions = {}): UsageIssue[] { + const { types, ...measure } = options + const asked = types + ? MEASUREMENTS.filter((measurement) => measurement.types.some((type) => types.has(type))) + : MEASUREMENTS + if (asked.length === 0) return [] + const shapes = usageShapes(nodes) + return asked.flatMap((measurement) => measurement.run(nodes, shapes, measure)) +} From 2d5dc8a606bf63b6dcae3c43a5dd06c060a2e914 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 11 Nov 2026 11:02:24 -0500 Subject: [PATCH 2/2] core: the usage path solver's heap allocates nothing per pop pop returned a new pair and every sift step swapped through two temporary arrays. It now returns the value, leaves the key on the heap, and moves the sifted entry once. Same comparisons in the same order, so the same paths. Co-Authored-By: Claude Fable 5.2 --- packages/core/src/spatial/usage-field/path.ts | 49 ++++++++++++------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/packages/core/src/spatial/usage-field/path.ts b/packages/core/src/spatial/usage-field/path.ts index d84bdc828f..0575a460f2 100644 --- a/packages/core/src/spatial/usage-field/path.ts +++ b/packages/core/src/spatial/usage-field/path.ts @@ -1,9 +1,10 @@ import { type BucketIndex, cellOf, cellX, cellZ, type Grid } from './field' -/** A binary min-heap on a numeric key. */ +/** A binary min-heap on a numeric key. `pop` returns the value and leaves its key in `key`. */ class Heap { private keys: number[] = [] private values: number[] = [] + key = 0 get size() { return this.keys.length @@ -17,34 +18,43 @@ class Heap { values.push(value) while (i > 0) { const parent = (i - 1) >> 1 - if (keys[i]! >= keys[parent]!) break - ;[keys[i], keys[parent]] = [keys[parent]!, keys[i]!] - ;[values[i], values[parent]] = [values[parent]!, values[i]!] + const above = keys[parent]! + if (key >= above) break + keys[i] = above + values[i] = values[parent]! i = parent } + keys[i] = key + values[i] = value } - pop(): [number, number] { + pop(): number { const keys = this.keys const values = this.values - const top: [number, number] = [keys[0]!, values[0]!] - const lastKey = keys.pop()! - const lastValue = values.pop()! - if (keys.length) { - keys[0] = lastKey - values[0] = lastValue + this.key = keys[0]! + const top = values[0]! + const key = keys.pop()! + const value = values.pop()! + const size = keys.length + if (size) { let i = 0 for (;;) { const l = 2 * i + 1 const r = l + 1 let m = i - if (l < keys.length && keys[l]! < keys[m]!) m = l - if (r < keys.length && keys[r]! < keys[m]!) m = r + let least = key + if (l < size && keys[l]! < least) { + m = l + least = keys[l]! + } + if (r < size && keys[r]! < least) m = r if (m === i) break - ;[keys[i], keys[m]] = [keys[m]!, keys[i]!] - ;[values[i], values[m]] = [values[m]!, values[i]!] + keys[i] = keys[m]! + values[i] = values[m]! i = m } + keys[i] = key + values[i] = value } return top } @@ -79,10 +89,10 @@ export function widestPath( heap.push(-F[s]!, s) } while (heap.size) { - const [negative, c] = heap.pop() + const c = heap.pop() if (done[c]) continue done[c] = 1 - const b = -negative + const b = -heap.key const i = c % g.W const j = (c / g.W) | 0 for (const [di, dj] of FOUR) { @@ -154,7 +164,8 @@ export function travel(g: Grid, pass: Uint8Array, sources: Iterable): Fl heap.push(0, s) } while (heap.size) { - const [d, c] = heap.pop() + const c = heap.pop() + const d = heap.key if (d > D[c]!) continue const i = c % g.W const j = (c / g.W) | 0 @@ -370,7 +381,7 @@ export function shortestRoute( cost[from] = 0 heap.push(heuristic(from), from) while (heap.size) { - const [, c] = heap.pop() + const c = heap.pop() if (c === to) break const i = c % g.W const j = (c / g.W) | 0