Skip to content

feat(ifc-reference-reader): publish the filter receipt on the progress channel - #410

Merged
pawellisowski merged 1 commit into
mainfrom
routine/issue-407-selected-on-progress
Aug 13, 2026
Merged

feat(ifc-reference-reader): publish the filter receipt on the progress channel#410
pawellisowski merged 1 commit into
mainfrom
routine/issue-407-selected-on-progress

Conversation

@pawellisowski

Copy link
Copy Markdown
Contributor

Summary

  • read-model now publishes the selected filter receipt on the tessellate progress record — once, after the filter resolves to expressIDs and before the first segment, which is the last moment a consumer can still decide not to draw. Refs read-model: publish the selected filter receipt on the progress channel, so a filtered read can be drawn progressively #407.
  • Absent selected keeps its existing meaning ("this bridge cannot answer"), so an unfiltered read publishes none and no existing consumer sees a change.
  • The echoed lists are bounded, because the runtime drops an over-long record whole rather than truncating it — see The part the issue does not cover below.

The problem

A consumer drawing batch-size segments as they arrive cannot tell two runs apart while it draws: a bridge that honoured storeys: [L2] and is streaming one floor, and a pre-1.2.0 bridge that ignored the filter, exited zero, and is streaming the whole building. Both are just geometry turning up. selected settles it, but it rides on the node output — available only once the walk is over, which is the wait progressive delivery exists to remove. So a progressive consumer had no honest option but to refuse to draw filtered reads at all, which is why floless.app ships progressive reference-model import for unfiltered reads only.

Type of change

  • Other (specify): additive capability on an existing agent + its bridge

Decalog check

  • This change respects all five decalog truths (app=text, AI=runtime, OSS=inherent, no vendor in the loop, AECO=wedge-not-limit).

What changed

cli-connection-reader/index.mjs

  • readModel gains an optional opts.onSelected(report), called exactly once with the receipt (or null when no filter was applied), before any mesh streams. It mirrors the existing opts.onObject sink, so the progress channel stays a concern of the streaming layer rather than leaking into readModel.
  • readModelStreamed emits the tessellate record from that callback instead of before the read. The record's existing fields (phase, message) are unchanged; only selected is new.
  • The selection now resolves before the material and property-set maps rather than after. Those cost real time on the files this command exists for (property sets are collected for every element in the file) and the selection needs neither — selectExpressIds reads the storey map and nothing else. Leaving them in front would have made the receipt arrive that much later, which is dead air a progressive consumer waits through. Behaviour is unchanged: both maps are consulted only from onMesh, which cannot run until StreamMeshes is called.

cli-connection-reader/progress.mjs — exports MAX_RECORD_BYTES and progressRecordBytes(), so the producer can measure a record against the limit the runtime will actually apply.

Docsread-model.md gains a May I draw this? section; manifest.yaml1.5.0 (additive, MINOR) with the version-history note the file keeps for every behaviour change.

The part the issue does not cover: the record can delete itself

cli/src/runtime/progress.rs drops a record over MAX_RECORD_BYTES (8 KiB) whole, not truncated, and silently — the channel is advisory. A receipt echoes caller-supplied values, and ids is documented for "re-reading a known selection", so a few hundred GlobalIds is ordinary use and already over budget.

Emitting the full echo would therefore have deleted the record precisely when the caller filtered hardest — and the consumer would read that absence as "old bridge, do not draw". The feature would be silently off in its best case, which is worse than not shipping it.

So the echoed lists (ids, unmatched, ifcTypes, storeys) are dropped largest-first until the record fits, and every one dropped is named in selected.elided:

{ "phase": "tessellate",
  "selected": { "storeys": ["L2"], "candidates": 4980, "elided": ["ids", "unmatched"] } }

Dropped rather than shortened, deliberately: a half-echoed list is a different filter, and a consumer comparing it against what it sent would find a mismatch it could only read as "this bridge honoured something else". candidates always survives — it is the one number that says a filter ran at all. The response's own selected is never elided, so the complete artifact always carries the full receipt.

Verification

Run on Linux with the pinned toolchain (1.95.0) and CI's apt deps (clang libsecret-1-dev libdbus-1-dev pkg-config):

Gate Result
cli-connection-reader: node --test pass — 121 tests, 73 pass, 0 fail, 48 skipped (the same 48 that skip on the baseline: they need large sample files not in the repo)
cli/: cargo fmt --all -- --check pass
cli/: cargo clippy --all-targets -- -D warnings pass
cli/: cargo test pass — 859 unit + every integration suite, 0 failed, 1 ignored

No Rust changed; the cli/ gates are the unchanged-baseline check.

Mutation evidence

Every test added was proven red by breaking the code it covers, one mutation at a time, then restored.

# Mutation Tests that went red
M1b an unfiltered read publishes a receipt anyway an UNFILTERED read publishes no receipt, so absent keeps its meaning
M2 elision picks the smallest list first tessellateRecord: the LARGEST list goes first, so the fewest are lost
M5 no bounding at all — emit the full receipt a huge ids filter still publishes a record the runtime will accept, …LARGEST list goes first…, …every echoed list can go…
M6 pre-#407 emission restored (record published before the read, receipt lost) the filter receipt rides on tessellate, before the first segment, a huge ids filter still publishes…
M7 the cap drifts in Rust (8 → 16 KiB in progress.rs) the producer measures against the runtime's OWN limit, not a copy of the number
M8 elided lists not named in elided a huge ids filter still publishes…, …LARGEST list goes first…, …every echoed list can go…
M9 elision drops candidates along with the lists a huge ids filter still publishes…, …LARGEST list goes first…, …every echoed list can go…

Two mutations I tried were no-ops, and I am reporting them rather than counting them:

  • M1 — dropping the selection.applied ? gate changed nothing, because selectExpressIds returns no report key at all when no filter was given, so tessellateRecord already receives undefined. The gate is belt-and-braces that states the intent; M1b above is the mutation that models the real regression.
  • M4 — adding candidates to ECHOED_LISTS changed nothing: the elision filter requires Array.isArray, so a number can never be picked, and it is the smallest value anyway so the loop stops before reaching it. candidates is protected structurally rather than by that list. M9 is the single-edit mutation that does kill the assertion.

Notes for reviewers

  • The re-ordering inside readModel is the one behavioural risk worth a second pair of eyes. My reasoning that it is safe: materials and propertySets are captured by onMesh only, and onMesh cannot fire before StreamMeshes/StreamAllMeshes is called further down. selectExpressIds takes (api, modelID, opts, storeys) and touches neither map. All 73 running bridge tests agree, but they are not proof of absence.
  • tessellateRecord is not wrapped in a try/catch, unlike the channel write in emitProgress. The "advisory, never fail a read" doctrine covers writing to a channel that may be missing or unwritable; building the record is our own pure code over data the bridge itself produced, and swallowing a throw there would hide a defect rather than tolerate an environment. Happy to be argued out of this.
  • A pre-1.5.0 bridge under a 1.5.0 manifest publishes no receipt, which is indistinguishable from an unfiltered read on the channel alone — but a consumer knows whether it passed a filter, so the ambiguity is not reachable in practice. This is the same absence-means-cannot-answer rule the rest of the contract already runs on.
  • The elided mechanism is new surface. If a reviewer would rather see the record simply omitted when it cannot fit, say so — but that puts the feature silently off in exactly the large-filter case it is most wanted.

Review

Codex is primary per CLAUDE.md §"PR review — non-negotiable"; requested on this PR. Result will be recorded here and in the routine log (#342).


Generated by Claude Code

…s channel (#407)

A consumer drawing `batch-size` segments as they arrive cannot tell two runs
apart while it draws: a bridge that honoured `storeys: [L2]` and is streaming
one floor, and a pre-1.2.0 bridge that ignored the filter, exited zero, and is
streaming the whole building. Both are just geometry turning up. The `selected`
receipt settles it, but it rides on the node output — available only once the
walk is over, which is the wait progressive delivery exists to remove. So a
progressive consumer had no honest option but to refuse to draw filtered reads,
which is why floless.app ships progressive import for unfiltered reads only.

Publish the same receipt on the `tessellate` record: once, after the filter has
resolved to expressIDs and before the first segment — the last moment a consumer
can still decide not to draw. Absent `selected` keeps its meaning (this bridge
cannot answer), so an unfiltered read publishes none and no existing consumer
sees a change.

The selection is now resolved before the material and property-set maps rather
than after. Those cost real time on the files this command exists for and the
selection needs neither; leaving them in front would have made the receipt
arrive that much later, which is dead air a progressive consumer waits through.
Both maps are read only from `onMesh`, so behaviour is unchanged.

The echoed lists are bounded, because the runtime drops an over-long record
whole rather than truncating it. `ids` is documented for re-reading a known
selection, so a few hundred GlobalIds is ordinary use and more than the 8 KiB
budget — echoing them verbatim would delete the record precisely when the filter
was largest, and the consumer would read that absence as "old bridge, do not
draw". Lists are dropped largest-first until the record fits and each one is
named in `selected.elided`; `candidates` always survives. Dropped rather than
shortened, because a half-echoed list is a different filter.

Refs #407

Copy link
Copy Markdown
Contributor Author

@codex review


Generated by Claude Code

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: 007d8b3249

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@pawellisowski
pawellisowski merged commit dcb0c20 into main Aug 13, 2026
4 checks passed

Copy link
Copy Markdown
Contributor Author

Review result — Codex, clean.

Codex reviewed 007d8b3249 — the final commit and the merged HEAD — and reported "Didn't find any major issues." No findings to address, so nothing was pushed after the review and the approval covers exactly the code that landed.

CI green on the same commit: fmt + clippy + test, connection-reader bridge tests, stats, no Claude co-author trailers — all success.

Merged per the CLAUDE.md §"Git workflow" carve-out (cross-model review on the final commit + CI green on that commit). Squashed as dcb0c209.

The three judgement calls flagged under Notes for reviewers — the readModel re-ordering, the un-caught tessellateRecord, and the elided mechanism — went unchallenged by Codex; they are recorded there rather than silently settled, in case a later reader wants to revisit them.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants