LearnNewsExamplesServices
Frontmatter
id15432
titleDual-window conversion sensor: the contract tier — min-axis geometry, dead-band hysteresis, pointer-gated decisions
stateClosed
labels
enhancementarchitecture
assigneesneo-fable-clio
createdAtJul 18, 2026, 8:05 AM
updatedAtJul 18, 2026, 12:12 PM
githubUrlhttps://github.com/neomjs/neo/issues/15432
authorneo-fable-clio
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtJul 18, 2026, 12:12 PM

Dual-window conversion sensor: the contract tier — min-axis geometry, dead-band hysteresis, pointer-gated decisions

Closed Backlog/active-chunk-7 enhancementarchitecture
neo-fable-clio
neo-fable-clio commented on Jul 18, 2026, 8:05 AM

Context

Foundation leaf of #15395 (epic #15239, G3 family), split per this epic's twice-proven tier pattern (#15407 contract ← #15244 host; #15426 foundation ← #15243 matrix): #15395's wire-in, empirical threshold calibration, and headed witness cells are all gated on the #15246 claim protocol — the ADR 0029 §2.8.1 arbitration that owns the pointerInTarget feed (wiring conversion to the legacy first-intersecting resolution §2.8.1 explicitly replaces would build on deprecated substrate). The conversion contract's mathematical heart is NOT gated, and every consuming leaf needs its decision semantics settled. This ticket is the Resolves target for the contract-tier PR; #15395 stays open owning calibration + wire-in + headed cells behind #15246.

The Problem

#15395 proves the landed single-denominator intersection ratio (src/draggable/container/SortZone.mjs checkWindowBoundary, area(intersection)/area(dragged)) is mathematically broken for popup-over-vessel: a large dragged window over a small target caps below any usable threshold, and the mirror case fails the mirror way — conversion is UNREACHABLE for realistic size pairs, in both directions, and both windows are freely resizable mid-session. No module owns the dual-window decision; without a settled contract, #15246/#15247 and the #15395 wire-in would each improvise geometry.

The Fix

One pure, zero-import decision module — src/dashboard/DockVesselConversion.mjs, factory createVesselConversionSensor matching the landed createDockTearOutHandlers idiom (closure state, injected seams, host parameterizes, witnesses drive without a browser):

  1. Metric: per-axis overlap normalized by the SMALLER extent per axis — rx = overlapWidth / min(wA, wB), ry = overlapHeight / min(hA, hB) — composed via an injectable composeRatios seam (default min(rx, ry); #15395's empirical calibration owns the final pick). Computed from the LIVE rects of every sample; reachable (composed = 1.0) for ANY size pair in both directions.
  2. Dead-band hysteresis: convertThreshold (in) > revertThreshold (out), validated fail-loud at creation. The band IS the Schmitt trigger — a decision fires only on crossing its own threshold, so the #15413 false-flip class is structurally excluded (no arming flag needed: the convert-in moment sits above the revert band by construction, the inverse of the landed exit-inside-reattach-zone geometry that required arming). Shipped defaults are REVIEWABLE PLACEHOLDERS (band width mirrors the landed grammar's proven 0.2); calibrated product values are #15395's deliverable.
  3. Pointer gate, both directions: convert-in requires pointerInTarget; pointer leaving the target while converted fires convert-out regardless of rect overlap — rect overlap alone never converts AND never holds a conversion (intent stays pointer-owned, feeding the #15240 claim semantics unchanged).
  4. Decisions through seams, geometry through the record: required onConvertIn / onConvertOut seams (the §2.3-named actuator pair suspendWindowDrag / resumeWindowDrag is what a host binds them to); sample() returns the per-frame geometry record for observability. Silent idempotent reset() for gesture terminals — terminal choreography (commit, close, park) belongs to the §2.8.2 outcome machine and #15396, never to the sensor.
  5. Fail-closed under garbage geometry: missing/degenerate/non-finite rects compose to 0 — a converted sensor fed garbage REVERTS (NaN comparisons would otherwise hold conversion forever).

Design-time resolution recorded (resolves #15395's "may amend ADR 0029" clause negatively): NO new outcome-machine state is required. Conversion-in IS the geometric predicate for the existing DETACHED_MOVING → HOVERING_CLAIM transition (§2.8.2); conversion-out is its inverse; the §2.3 source hooks are the contract-named actuators. The module documents this mapping in its JSDoc contract prose.

Contract Ledger

Surface Direction Contract
createVesselConversionSensor({composeRatios?, convertThreshold?, revertThreshold?, onConvertIn, onConvertOut}){converted, reset, sample} provided The dual-window conversion decision authority: per-sample {sourceRect, targetRect, pointerInTarget} in, decisions through seams, geometry record returned. Zero imports; no window/DOM/model access.
ADR 0029 §2.8.2 outcome machine + §2.3 source hooks consumed (upstream authority) Conversion-in/out = the DETACHED_MOVING ⇄ HOVERING_CLAIM transition predicate; suspendWindowDrag/resumeWindowDrag are the actuators. No amendment: the sensor adds a predicate, never a state.
#15395 (wire-in + calibration + headed cells), #15246 (claim feed), #15396 (park actuation on convert) downstream consumers They bind the sensor; they never re-derive geometry. Threshold/composition changes land HERE, calibration receipts land on #15395.

Acceptance Criteria

  • Pure zero-import factory in src/dashboard/, seam/JSDoc idiom matching DockTearOut.mjs, with the §2.8.2 transition mapping + no-amendment resolution in the module contract prose.
  • Reachability proven by unit sweep: small-over-large, large-over-small, near-equal, and extreme-aspect size pairs each attain composed = 1.0 at full min-extent overlap and fire convert-in with the pointer gate satisfied.
  • Single-fire hysteresis: a monotonic crossing fires exactly one convert-in; jitter samples inside the dead band fire nothing; monotonic retreat fires exactly one convert-out (the slow-crossing zero-flicker witness at contract tier).
  • Pointer-gate truth table: composed = 1.0 with pointer outside never converts; pointer exit while converted reverts at unchanged rects; pointer re-entry above threshold re-converts.
  • Live-rect renormalization: a mid-sequence target resize re-derives normalization per sample (the #15395 post-resize scenario at unit grain).
  • Fail-loud config: convertThreshold <= revertThreshold, out-of-range thresholds, or missing/non-function seams throw at creation with contract-naming messages.
  • Fail-closed geometry: degenerate (zero-extent) and non-finite rects compose to 0; a converted sensor reverts on garbage input — witnessed.
  • Silent idempotent reset(): no seam emission, safe to call repeatedly, next gesture re-fires cleanly — witnessed.

Out of Scope

The wire-in into the drag path and the pointerInTarget production (#15395, behind #15246) · empirical threshold/composition calibration + headed matrix cells (#15395) · claim arbitration (#15246) · vessel park/re-show actuation (#15396) · any outcome-machine or ADR 0029 change (resolved: none needed).

Decision Record impact

depends-on ADR 0029 §2.8.2/§2.3 — consumed as upstream authority, explicitly NOT amended (the design-time resolution this ticket records).

Related

Parent #15395 (stays open; owns the empirical + headed remainder) · epic #15239 · #15240 (outcome machine) · #15246 (claim feed prerequisite for the wire-in) · #15396 (companion actuation) · #15408/PR (the createDockTearOutHandlers idiom this matches).

Live latest-open sweep: checked 2026-07-18T06:03Z (top-20 open via gh issue list); #15395/#15396 are the only conversion-family leaves — no contract-tier equivalent exists. A2A sweep: my own [lane-claim] f86f6c9a covers this lane; no competing claim.

Origin Session ID: 0c8fc4d9-2456-44fd-b120-048402bb9839

Retrieval Hint: query_raw_memories("vessel conversion sensor contract tier min-axis dead-band pointer gate no ADR amendment")