Implement EANCH-WP-0002 and EANCH-WP-0003 anchor hardening and DOM selectors
All checks were successful
CI Smoke / host-smoke (push) Successful in 4s
CI Smoke / container-smoke (push) Successful in 31s

WP-0002: stale vs unresolved resolution, orphaned flag/helper, bounded
fuzzy quote recovery, and PdfViewerAdapter promotion with resolveSelectors
integration. WP-0003: DomSelectionCapture, DOM create/resolve ladder,
HtmlViewerAdapter under evidence-anchor/dom, and finished workplans.

Verification: 42 anchor tests, citation-evidence typecheck + 51 tests green.
This commit is contained in:
tegwick 2026-07-09 09:18:42 +02:00
parent e4582b52fa
commit a1af0c6a45
22 changed files with 1118 additions and 105 deletions

View file

@ -20,7 +20,8 @@
".": "./src/index.ts",
"./selectors": "./src/selectors/index.ts",
"./types": "./src/types.ts",
"./pdf": "./src/pdf/index.ts"
"./pdf": "./src/pdf/index.ts",
"./dom": "./src/dom/index.ts"
},
"scripts": {
"test": "vitest run",

30
src/dom/dom-path.test.ts Normal file
View file

@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { Window } from "happy-dom";
import { captureFromRange, domPathToNode, nodeAtPath } from "./dom-path";
describe("dom-path", () => {
it("round-trips a child-index path", () => {
const win = new Window();
const doc = win.document;
doc.body.innerHTML = "<article><p>Hello <b>world</b></p></article>";
const bold = doc.querySelector("b")!;
const root = doc.body as unknown as Node;
const path = domPathToNode(root, bold as unknown as Node);
expect(path).toBeDefined();
expect(nodeAtPath(root, path!)).toBe(bold);
});
it("captures a range as DomSelectionCapture", () => {
const win = new Window();
const doc = win.document;
doc.body.innerHTML = "<p>The quick brown fox</p>";
const textNode = doc.querySelector("p")!.firstChild!;
const range = doc.createRange();
range.setStart(textNode, 4);
range.setEnd(textNode, 14);
const capture = captureFromRange(doc.body as unknown as Node, range as unknown as Range, "quick brown");
expect(capture?.kind).toBe("dom");
expect(capture?.text).toBe("quick brown");
expect(capture?.startPath).toBeDefined();
});
});

58
src/dom/dom-path.ts Normal file
View file

@ -0,0 +1,58 @@
import type { DomNodePath } from "@citation-evidence/engine/shared";
import type { DomSelectionCapture } from "../types";
/** Walk a DOM tree and return the child-index path to `node`. */
export function domPathToNode(root: Node, node: Node): DomNodePath | null {
if (root === node) return [];
const path: number[] = [];
function walk(current: Node, target: Node): boolean {
if (current === target) return true;
const children = childNodes(current);
for (let i = 0; i < children.length; i++) {
path.push(i);
if (walk(children[i]!, target)) return true;
path.pop();
}
return false;
}
return walk(root, node) ? path : null;
}
/** Resolve a child-index path from `root` to a node, or null if invalid. */
export function nodeAtPath(root: Node, path: DomNodePath): Node | null {
let current: Node = root;
for (const index of path) {
const children = childNodes(current);
const child = children[index];
if (!child) return null;
current = child;
}
return current;
}
function childNodes(node: Node): Node[] {
return Array.from(node.childNodes);
}
/** Build a `DomSelectionCapture` from a browser `Range` relative to `root`. */
export function captureFromRange(
root: Node,
range: Range,
text: string,
structuralPath?: DomSelectionCapture["structuralPath"],
): DomSelectionCapture | null {
const startPath = domPathToNode(root, range.startContainer);
const endPath = domPathToNode(root, range.endContainer);
if (!startPath || !endPath) return null;
return {
kind: "dom",
text,
startPath,
startOffset: range.startOffset,
endPath,
endOffset: range.endOffset,
...(structuralPath ? { structuralPath } : {}),
};
}

View file

@ -0,0 +1,156 @@
/**
* HTML/Markdown viewer adapter for non-paginated documents.
*
* Renders canonical HTML, captures DOM selections, and supports scroll/highlight
* via resolved text positions. Viewer-specific code stays under `src/dom/`.
*/
import {
useCallback,
useEffect,
useMemo,
useRef,
type ReactNode,
} from "react";
import type { DocumentRepresentation, Selector } from "@citation-evidence/engine/shared";
import { createSelectors, resolveSelectors } from "../selectors";
import type {
AnchorResolution,
DomSelectionCapture,
HighlightRenderOptions,
ResolvedAnchorTarget,
} from "../types";
import { captureFromRange } from "./dom-path";
export interface HtmlViewerDebugOptions {
readonly logSelections?: boolean;
}
export interface HtmlViewerAdapterProps {
readonly html: string;
readonly representation: DocumentRepresentation;
readonly storedAnnotations?: readonly StoredHtmlAnnotation[];
readonly scrollToAnnotationId?: string;
readonly activeAnnotationId?: string | null;
readonly debug?: HtmlViewerDebugOptions;
onSelectionCaptured?(capture: DomSelectionCapture, selectors: Selector[]): void;
onHighlightClicked?(annotationId: string): void;
}
export interface StoredHtmlAnnotation {
readonly id: string;
readonly text: string;
readonly selectors: readonly Selector[];
}
export function HtmlViewerAdapter(props: HtmlViewerAdapterProps): ReactNode {
const {
html,
representation,
storedAnnotations = [],
scrollToAnnotationId,
activeAnnotationId,
debug,
onSelectionCaptured,
onHighlightClicked,
} = props;
const rootRef = useRef<HTMLDivElement | null>(null);
const handleMouseUp = useCallback(() => {
const root = rootRef.current;
if (!root || typeof window === "undefined") return;
const selection = window.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
if (!root.contains(range.commonAncestorContainer)) return;
const text = selection.toString();
if (!text) return;
const capture = captureFromRange(root, range, text);
if (!capture) return;
const selectors = createSelectors(capture, representation);
if (debug?.logSelections) {
console.log("[evidence-anchor/dom] selection", { capture, selectors });
}
onSelectionCaptured?.(capture, selectors);
}, [representation, debug, onSelectionCaptured]);
const renderedHtml = useMemo(() => {
let body = html;
for (const ann of storedAnnotations) {
const resolution = resolveSelectors(ann.selectors, representation);
const span = resolution.candidates[0]?.textPosition;
if (!span || !representation.canonicalText) continue;
const quote = representation.canonicalText.slice(span.start, span.end);
if (!quote) continue;
const mark = `<mark data-highlight-id="${ann.id}" data-ce-active="${activeAnnotationId === ann.id ? "true" : "false"}">${quote}</mark>`;
body = body.replace(quote, mark);
}
return body;
}, [html, storedAnnotations, representation, activeAnnotationId]);
useEffect(() => {
if (!scrollToAnnotationId || !rootRef.current) return;
const mark = rootRef.current.querySelector(
`[data-highlight-id="${CSS.escape(scrollToAnnotationId)}"]`,
);
mark?.scrollIntoView({ block: "center", behavior: "smooth" });
}, [scrollToAnnotationId, renderedHtml]);
return (
<div
ref={rootRef}
className="ea-html-viewer"
onMouseUp={handleMouseUp}
onClick={(e) => {
const target = (e.target as HTMLElement).closest("[data-highlight-id]");
if (target instanceof HTMLElement && target.dataset.highlightId) {
onHighlightClicked?.(target.dataset.highlightId);
}
}}
dangerouslySetInnerHTML={{ __html: renderedHtml }}
/>
);
}
export async function resolveHtmlSelectors(
selectors: readonly Selector[],
representation: DocumentRepresentation,
): Promise<AnchorResolution> {
return resolveSelectors(selectors, representation);
}
export async function scrollToHtmlTarget(
root: HTMLElement,
target: ResolvedAnchorTarget,
representation: DocumentRepresentation,
): Promise<void> {
const span = target.textPosition;
if (!span || !representation.canonicalText) return;
const quote = representation.canonicalText.slice(span.start, span.end);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let node: Node | null;
while ((node = walker.nextNode())) {
const text = node.textContent ?? "";
const idx = text.indexOf(quote);
if (idx === -1) continue;
const range = document.createRange();
range.setStart(node, idx);
range.setEnd(node, idx + quote.length);
const rect = range.getBoundingClientRect();
if (rect.height > 0) {
range.startContainer.parentElement?.scrollIntoView({ block: "center", behavior: "smooth" });
return;
}
}
}
export async function renderHtmlHighlight(
target: ResolvedAnchorTarget,
opts?: HighlightRenderOptions,
): Promise<void> {
void target;
void opts;
// Highlights are rendered inline during `HtmlViewerAdapter` render via stored annotations.
}

11
src/dom/index.ts Normal file
View file

@ -0,0 +1,11 @@
export {
HtmlViewerAdapter,
resolveHtmlSelectors,
scrollToHtmlTarget,
renderHtmlHighlight,
type HtmlViewerAdapterProps,
type HtmlViewerDebugOptions,
type StoredHtmlAnnotation,
} from "./html-viewer-adapter";
export { captureFromRange, domPathToNode, nodeAtPath } from "./dom-path";

View file

@ -22,8 +22,11 @@ export {
// PDF adapter + helpers (re-exported from the src/pdf/ boundary).
export {
PdfViewerAdapter,
PdfSpikeViewer,
getHighlightClientRects,
type PdfViewerAdapterProps,
type PdfViewerDebugOptions,
selectorsFromPdfCapture,
findPdfRectSelector,
findTextQuoteSelector,

View file

@ -26,10 +26,13 @@ export {
type ScrollToHighlightState,
} from "./scroll-job";
// Concrete PDF viewer adapter (spike) + its contract surface.
// Production PDF viewer adapter + its contract surface.
export {
PdfViewerAdapter,
PdfSpikeViewer,
getHighlightClientRects,
type PdfViewerAdapterProps,
type PdfViewerDebugOptions,
type PdfSpikeViewerProps,
type StoredAnnotation,
} from "./pdf-viewer-adapter-spike";

View file

@ -46,7 +46,12 @@ import "react-pdf-highlighter-plus/style/pdf_viewer.css";
import "./highlight-styles.css";
import "./debug-textlayer.css";
import type { NormalizedRect, Selector } from "@citation-evidence/engine/shared";
import type {
DocumentRepresentation,
NormalizedRect,
Selector,
} from "@citation-evidence/engine/shared";
import { resolveSelectors } from "../selectors";
import type { AnchorResolution, PdfSelectionCapture, ResolvedAnchorTarget } from "../types";
import { findPdfRectSelector, selectorsFromPdfCapture, unionRect } from "./pdf-selector-math";
import { runScrollToHighlightJob } from "./scroll-job";
@ -63,7 +68,29 @@ function highlightFromSelectors(
id: string,
text: string,
selectors: readonly Selector[],
representation?: DocumentRepresentation,
): Highlight | null {
if (representation) {
const resolution = resolveSelectors(selectors, representation);
const rectSel = findPdfRectSelector(selectors);
const page = resolution.candidates[0]?.page ?? rectSel?.page;
const rects = resolution.candidates[0]?.rects ?? rectSel?.rects;
if (page && rects && rects.length > 0) {
const boundingRect = unionRect(rects);
if (boundingRect) {
const scaledRects = rects.map((r) => toScaled(r, page));
return {
id,
type: "text",
content: { text },
position: {
boundingRect: toScaled(boundingRect, page),
rects: scaledRects,
} satisfies ScaledPosition,
};
}
}
}
const rectSel = findPdfRectSelector(selectors);
if (!rectSel) return null;
const boundingRect = unionRect(rectSel.rects);
@ -188,9 +215,19 @@ export function getHighlightClientRects(annotationId: string): DOMRect | null {
return new DOMRect(left, top, right - left, bottom - top);
}
export interface PdfSpikeViewerProps {
export interface PdfViewerDebugOptions {
readonly debugTextLayer?: boolean;
readonly hideCanvas?: boolean;
readonly hideTextLayer?: boolean;
readonly hideAnnotationLayer?: boolean;
readonly hideXfaLayer?: boolean;
}
export interface PdfViewerAdapterProps {
/** URL of the PDF to load (served by Vite dev server). */
readonly pdfUrl: string;
/** Document representation for selector resolution (enables text-based reload). */
readonly representation?: DocumentRepresentation;
/** Previously-saved selector sets to restore on mount. */
readonly storedAnnotations: readonly StoredAnnotation[];
/** Called when the user produces a new selection. */
@ -213,23 +250,23 @@ export interface PdfSpikeViewerProps {
* The receiver typically activates the matching evidence item.
*/
onHighlightClicked?(annotationId: string): void;
/**
* When true, paint the PDF text-layer spans in light grey so it's
* obvious which glyphs have a selectable text overlay and which are
* image-only. Also logs every onSelection event to the console.
*/
/** Spike-only debug flags — not part of the production adapter contract. */
readonly debug?: PdfViewerDebugOptions;
/** @deprecated Pass via `debug.debugTextLayer` */
readonly debugTextLayer?: boolean;
/**
* Hide specific PDF.js layers so you can see what sits underneath.
* Helps diagnose layer-stacking issues (e.g. "is the text layer
* covering the canvas content?").
*/
/** @deprecated Pass via `debug.hideCanvas` */
readonly hideCanvas?: boolean;
/** @deprecated Pass via `debug.hideTextLayer` */
readonly hideTextLayer?: boolean;
/** @deprecated Pass via `debug.hideAnnotationLayer` */
readonly hideAnnotationLayer?: boolean;
/** @deprecated Pass via `debug.hideXfaLayer` */
readonly hideXfaLayer?: boolean;
}
/** @deprecated Use `PdfViewerAdapterProps` */
export type PdfSpikeViewerProps = PdfViewerAdapterProps;
/**
* Nudge the PDF scroll container so `highlight` sits vertically centred.
* Best-effort: depends on highlight layer DOM being present after scroll.
@ -271,21 +308,28 @@ export interface StoredAnnotation {
* - reconstructs and renders `storedAnnotations` immediately on load
* - scrolls to `scrollToAnnotationId` if its highlight can be reconstructed
*/
export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
export function PdfViewerAdapter(props: PdfViewerAdapterProps) {
const {
pdfUrl,
representation,
storedAnnotations,
onSelectionCaptured,
scrollToAnnotationId,
scrollRequestKey,
activeAnnotationId,
onHighlightClicked,
debugTextLayer,
hideCanvas,
hideTextLayer,
hideAnnotationLayer,
hideXfaLayer,
debug,
debugTextLayer: debugTextLayerProp,
hideCanvas: hideCanvasProp,
hideTextLayer: hideTextLayerProp,
hideAnnotationLayer: hideAnnotationLayerProp,
hideXfaLayer: hideXfaLayerProp,
} = props;
const debugTextLayer = debug?.debugTextLayer ?? debugTextLayerProp;
const hideCanvas = debug?.hideCanvas ?? hideCanvasProp;
const hideTextLayer = debug?.hideTextLayer ?? hideTextLayerProp;
const hideAnnotationLayer = debug?.hideAnnotationLayer ?? hideAnnotationLayerProp;
const hideXfaLayer = debug?.hideXfaLayer ?? hideXfaLayerProp;
const onHighlightClickedRef = useRef(onHighlightClicked);
onHighlightClickedRef.current = onHighlightClicked;
const handleHighlightClicked = useCallback((annotationId: string) => {
@ -318,7 +362,7 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
const out: Highlight[] = [];
const skipped: { id: string; reason: string }[] = [];
for (const a of storedAnnotations) {
const h = highlightFromSelectors(a.id, a.text, a.selectors);
const h = highlightFromSelectors(a.id, a.text, a.selectors, representation);
if (h) out.push(h);
else skipped.push({ id: a.id, reason: "no PdfRectSelector / empty boundingRect" });
}
@ -336,7 +380,7 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
});
}
return out;
}, [storedAnnotations, debugTextLayer]);
}, [storedAnnotations, representation, debugTextLayer]);
const highlightsRef = useRef(highlights);
highlightsRef.current = highlights;
@ -420,5 +464,8 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
);
}
/** @deprecated Use `PdfViewerAdapter` */
export const PdfSpikeViewer = PdfViewerAdapter;
// Re-export the §5 contract surface so callers see anchor as one entry point.
export type { AnchorResolution, ResolvedAnchorTarget, PdfSelectionCapture };

View file

@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import type { DocumentRepresentation } from "@citation-evidence/engine/shared";
import type { DocumentId, RepresentationId } from "@citation-evidence/engine/shared";
import type {
DomRangeSelector,
StructuralSelector,
TextPositionSelector,
TextQuoteSelector,
} from "@citation-evidence/engine/shared";
import { createSelectors } from "./create";
import type { DomSelectionCapture } from "../types";
const text = "## Intro\n\nThe quick brown fox jumps.\n\n## Outro";
function repr(): DocumentRepresentation {
return {
id: "rep_dom" as RepresentationId,
documentId: "doc_dom" as DocumentId,
representationType: "html-dom",
contentHash: "test",
canonicalText: text,
structureMap: [
{
globalStart: 0,
globalEnd: text.indexOf("\n\n## Outro"),
path: [{ kind: "section", index: 0, label: "Intro" }],
containerPath: [0, 0, 0],
},
],
offsetMap: [{ page: 1, globalStart: 0, globalEnd: text.length, pageLength: text.length }],
generatedAt: "2026-07-09T00:00:00.000Z",
};
}
function capture(selected: string): DomSelectionCapture {
const start = text.indexOf(selected);
return {
kind: "dom",
text: selected,
startPath: [0, 0, 0],
startOffset: start,
endPath: [0, 0, 0],
endOffset: start + selected.length,
structuralPath: [{ kind: "section", index: 0, label: "Intro" }],
};
}
describe("createSelectors (dom)", () => {
it("emits TextQuote, TextPosition, DomRange, and Structural selectors", () => {
const sels = createSelectors(capture("brown fox"), repr());
expect(sels.find((s): s is TextQuoteSelector => s.type === "TextQuoteSelector")?.exact).toBe("brown fox");
const pos = sels.find((s): s is TextPositionSelector => s.type === "TextPositionSelector");
expect(pos?.start).toBe(text.indexOf("brown fox"));
expect(sels.find((s): s is DomRangeSelector => s.type === "DomRangeSelector")).toBeDefined();
const structural = sels.find((s): s is StructuralSelector => s.type === "StructuralSelector");
expect(structural?.path[0]?.kind).toBe("section");
expect(structural!.startOffset).toBeGreaterThanOrEqual(0);
});
});

View file

@ -5,25 +5,21 @@
* from `wiki/SharedContracts.md` §3 (selector redundancy) and the create
* half of the `AnchorAdapter` contract in
* `wiki/ArchitectureOverview.md` §3.3.
*
* Output guarantee: every returned `Selector[]` includes a
* `TextQuoteSelector` (always) and adds `TextPositionSelector`,
* `PdfRectSelector`, `PdfPageTextSelector` only when the underlying data
* actually supports them. Resolvers can rely on the union being trimmed
* a missing selector means "not available", not "skipped".
*/
import type { DocumentRepresentation } from "@citation-evidence/engine/shared";
import { normalize } from "@citation-evidence/engine/shared";
import type {
DomRangeSelector,
PdfPageTextSelector,
PdfRectSelector,
Selector,
StructuralSelector,
TextPositionSelector,
TextQuoteSelector,
} from "@citation-evidence/engine/shared";
import type { PdfSelectionCapture, SelectionCapture } from "../types";
import type { DomSelectionCapture, PdfSelectionCapture, SelectionCapture } from "../types";
/** Default characters of prefix/suffix context stored on TextQuoteSelector. */
export const DEFAULT_CONTEXT_CHARS = 32;
@ -37,8 +33,9 @@ export function createSelectors(
representation: DocumentRepresentation,
options: CreateSelectorsOptions = {},
): Selector[] {
// `SelectionCapture` is a discriminated union. The DOM branch is `never`
// in MVP, so the only runtime shape is `PdfSelectionCapture`.
if (capture.kind === "dom") {
return createSelectorsFromDomCapture(capture, representation, options);
}
return createSelectorsFromPdfCapture(capture, representation, options);
}
@ -56,14 +53,9 @@ function createSelectorsFromPdfCapture(
? findAllOccurrences(canonicalText, normalizedQuote)
: [];
// Locate the match that falls on the capture's page (when offsetMap is
// known); otherwise fall back to the first match. If there is no match,
// we still emit a quote-only TextQuoteSelector so the annotation is
// recoverable later if the representation is rebuilt.
const pageRange = representation.offsetMap?.find((r) => r.page === capture.page);
const matchOffset = pickMatch(positions, pageRange);
// 1. TextQuoteSelector — always included.
if (normalizedQuote.length > 0) {
const quote = matchOffset !== null
? buildQuoteSelectorWithContext(canonicalText, matchOffset, normalizedQuote, contextChars)
@ -71,7 +63,6 @@ function createSelectorsFromPdfCapture(
out.push(quote);
}
// 2. TextPositionSelector — only when we have a unique-enough match.
if (matchOffset !== null) {
const pos: TextPositionSelector = {
type: "TextPositionSelector",
@ -81,7 +72,6 @@ function createSelectorsFromPdfCapture(
out.push(pos);
}
// 3. PdfRectSelector — straight from the capture; viewer-coordinate truth.
if (capture.rects.length > 0) {
const rect: PdfRectSelector = {
type: "PdfRectSelector",
@ -91,8 +81,6 @@ function createSelectorsFromPdfCapture(
out.push(rect);
}
// 4. PdfPageTextSelector — when we have offsetMap and a unique-enough match
// that falls inside the capture's page range.
if (matchOffset !== null && pageRange) {
if (matchOffset >= pageRange.globalStart && matchOffset + normalizedQuote.length <= pageRange.globalEnd) {
const pageText: PdfPageTextSelector = {
@ -108,6 +96,70 @@ function createSelectorsFromPdfCapture(
return out;
}
function createSelectorsFromDomCapture(
capture: DomSelectionCapture,
representation: DocumentRepresentation,
options: CreateSelectorsOptions,
): Selector[] {
const contextChars = options.contextChars ?? DEFAULT_CONTEXT_CHARS;
const normalizedQuote = normalize(capture.text).text;
const out: Selector[] = [];
const canonicalText = representation.canonicalText ?? "";
const positions = canonicalText.length > 0 && normalizedQuote.length > 0
? findAllOccurrences(canonicalText, normalizedQuote)
: [];
const matchOffset = positions.length === 1 ? positions[0]! : positions[0] ?? null;
if (normalizedQuote.length > 0) {
const quote = matchOffset !== null
? buildQuoteSelectorWithContext(canonicalText, matchOffset, normalizedQuote, contextChars)
: ({ type: "TextQuoteSelector", exact: normalizedQuote } satisfies TextQuoteSelector);
out.push(quote);
}
if (matchOffset !== null) {
out.push({
type: "TextPositionSelector",
start: matchOffset,
end: matchOffset + normalizedQuote.length,
} satisfies TextPositionSelector);
}
out.push({
type: "DomRangeSelector",
startPath: capture.startPath,
startOffset: capture.startOffset,
endPath: capture.endPath,
endOffset: capture.endOffset,
} satisfies DomRangeSelector);
if (capture.structuralPath && capture.structuralPath.length > 0) {
const block = representation.structureMap?.find(
(entry) => entry.path.every((seg, i) => {
const cap = capture.structuralPath![i];
return cap
&& seg.kind === cap.kind
&& seg.index === cap.index
&& seg.level === cap.level;
}),
);
const blockStart = block?.globalStart ?? 0;
const relStart = matchOffset !== null ? matchOffset - blockStart : capture.startOffset;
const relEnd = matchOffset !== null
? matchOffset + normalizedQuote.length - blockStart
: capture.endOffset;
out.push({
type: "StructuralSelector",
path: capture.structuralPath,
startOffset: relStart,
endOffset: relEnd,
} satisfies StructuralSelector);
}
return out;
}
function findAllOccurrences(haystack: string, needle: string): number[] {
if (needle.length === 0) return [];
const out: number[] = [];
@ -133,8 +185,6 @@ function pickMatch(
);
if (onPage !== undefined) return onPage;
}
// Multiple matches and no page hint — return the first; resolve.ts will
// need prefix/suffix to disambiguate.
return positions[0]!;
}
@ -154,4 +204,4 @@ function buildQuoteSelectorWithContext(
...(prefix.length > 0 ? { prefix } : {}),
...(suffix.length > 0 ? { suffix } : {}),
};
}
}

89
src/selectors/dom-path.ts Normal file
View file

@ -0,0 +1,89 @@
import type {
DomNodePath,
DomRangeSelector,
StructuralPathSegment,
StructuralSelector,
} from "@citation-evidence/engine/shared";
import type { StructureRange } from "@citation-evidence/engine/shared";
export function pathsEqual(a: DomNodePath, b: DomNodePath): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
export function pathStartsWith(path: DomNodePath, prefix: DomNodePath): boolean {
if (prefix.length > path.length) return false;
for (let i = 0; i < prefix.length; i++) {
if (path[i] !== prefix[i]) return false;
}
return true;
}
export function structuralPathsEqual(
a: readonly StructuralPathSegment[],
b: readonly StructuralPathSegment[],
): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const left = a[i]!;
const right = b[i]!;
if (
left.kind !== right.kind
|| left.index !== right.index
|| left.level !== right.level
) {
return false;
}
}
return true;
}
export function findStructureRange(
structureMap: readonly StructureRange[],
path: readonly StructuralPathSegment[],
): StructureRange | null {
return structureMap.find((entry) => structuralPathsEqual(entry.path, path)) ?? null;
}
export function findStructureRangeForDomRange(
structureMap: readonly StructureRange[],
selector: DomRangeSelector,
): StructureRange | null {
for (const entry of structureMap) {
if (!entry.containerPath) continue;
if (
pathStartsWith(selector.startPath, entry.containerPath)
&& pathStartsWith(selector.endPath, entry.containerPath)
) {
return entry;
}
}
return null;
}
export function resolveStructuralSpan(
structureMap: readonly StructureRange[],
selector: StructuralSelector,
): { start: number; end: number } | null {
const entry = findStructureRange(structureMap, selector.path);
if (!entry) return null;
const start = entry.globalStart + selector.startOffset;
const end = entry.globalStart + selector.endOffset;
if (start < entry.globalStart || end > entry.globalEnd || start >= end) return null;
return { start, end };
}
export function resolveDomRangeSpan(
structureMap: readonly StructureRange[],
selector: DomRangeSelector,
): { start: number; end: number } | null {
const entry = findStructureRangeForDomRange(structureMap, selector);
if (!entry) return null;
const start = entry.globalStart + selector.startOffset;
const end = entry.globalStart + selector.endOffset;
if (start < entry.globalStart || end > entry.globalEnd || start >= end) return null;
return { start, end };
}

View file

@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { findFuzzyQuoteMatches } from "./fuzzy";
describe("findFuzzyQuoteMatches", () => {
it("finds a lightly edited quote", () => {
const text = "The quick brown foks jumps over the lazy dog.";
const matches = findFuzzyQuoteMatches(text, { type: "TextQuoteSelector", exact: "brown fox" });
expect(matches.length).toBeGreaterThan(0);
expect(matches[0]!.matchedText).toContain("brown");
expect(matches[0]!.confidence).toBeLessThan(0.7);
expect(matches[0]!.confidence).toBeGreaterThanOrEqual(0.15);
});
it("returns empty for completely unrelated text", () => {
const matches = findFuzzyQuoteMatches(
"hello world",
{ type: "TextQuoteSelector", exact: "quantum entanglement" },
);
expect(matches).toEqual([]);
});
});

113
src/selectors/fuzzy.ts Normal file
View file

@ -0,0 +1,113 @@
import type { TextQuoteSelector } from "@citation-evidence/engine/shared";
export interface FuzzyQuoteMatch {
readonly offset: number;
readonly matchedText: string;
readonly distance: number;
readonly confidence: number;
}
/** Upper bound on edit distance relative to quote length. */
export const FUZZY_MAX_DISTANCE_RATIO = 0.2;
/** Confidence ceiling for fuzzy matches — below the exact ladder floor (0.7). */
export const FUZZY_CONFIDENCE_CEILING = 0.35;
/** Confidence floor for fuzzy matches. */
export const FUZZY_CONFIDENCE_FLOOR = 0.15;
/**
* Find bounded fuzzy quote matches in canonical text.
* Returns candidates sorted by ascending distance (best first).
*/
export function findFuzzyQuoteMatches(
canonicalText: string,
quote: TextQuoteSelector,
): readonly FuzzyQuoteMatch[] {
const needle = quote.exact;
if (needle.length === 0 || canonicalText.length === 0) return [];
const maxDistance = Math.max(1, Math.floor(needle.length * FUZZY_MAX_DISTANCE_RATIO));
const minLen = Math.max(1, needle.length - maxDistance);
const maxLen = needle.length + maxDistance;
const matches: FuzzyQuoteMatch[] = [];
for (let start = 0; start < canonicalText.length; start++) {
for (let len = minLen; len <= maxLen && start + len <= canonicalText.length; len++) {
const candidate = canonicalText.slice(start, start + len);
const distance = levenshtein(needle, candidate);
if (distance > maxDistance) continue;
const confidence = fuzzyConfidence(needle.length, candidate.length, distance);
if (confidence < FUZZY_CONFIDENCE_FLOOR) continue;
if (!prefixSuffixMatchesFuzzy(canonicalText, start, candidate, quote, maxDistance)) {
continue;
}
matches.push({ offset: start, matchedText: candidate, distance, confidence });
}
}
matches.sort((a, b) => a.distance - b.distance || a.offset - b.offset);
const deduped: FuzzyQuoteMatch[] = [];
for (const m of matches) {
if (deduped.some((d) => d.offset === m.offset && d.matchedText === m.matchedText)) continue;
deduped.push(m);
}
return deduped;
}
function fuzzyConfidence(needleLen: number, candidateLen: number, distance: number): number {
const denom = Math.max(needleLen, candidateLen, 1);
const raw = 1 - distance / denom;
const scaled = FUZZY_CONFIDENCE_FLOOR + raw * (FUZZY_CONFIDENCE_CEILING - FUZZY_CONFIDENCE_FLOOR);
return Math.min(FUZZY_CONFIDENCE_CEILING, Math.max(FUZZY_CONFIDENCE_FLOOR, scaled));
}
function prefixSuffixMatchesFuzzy(
canonicalText: string,
offset: number,
matchedText: string,
quote: TextQuoteSelector,
maxDistance: number,
): boolean {
if (quote.prefix !== undefined) {
const prefixEnd = offset;
const prefixStart = Math.max(0, prefixEnd - quote.prefix.length);
const actualPrefix = canonicalText.slice(prefixStart, prefixEnd);
if (levenshtein(actualPrefix, quote.prefix) > maxDistance) return false;
}
if (quote.suffix !== undefined) {
const suffixStart = offset + matchedText.length;
const suffixEnd = Math.min(canonicalText.length, suffixStart + quote.suffix.length);
const actualSuffix = canonicalText.slice(suffixStart, suffixEnd);
if (levenshtein(actualSuffix, quote.suffix) > maxDistance) return false;
}
return true;
}
function levenshtein(a: string, b: string): number {
if (a === b) return 0;
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
const rows = a.length + 1;
const cols = b.length + 1;
const matrix: number[] = new Array(rows * cols);
for (let i = 0; i < rows; i++) matrix[i * cols] = i;
for (let j = 0; j < cols; j++) matrix[j] = j;
for (let i = 1; i < rows; i++) {
for (let j = 1; j < cols; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
const del = matrix[(i - 1) * cols + j]! + 1;
const ins = matrix[i * cols + (j - 1)]! + 1;
const sub = matrix[(i - 1) * cols + (j - 1)]! + cost;
matrix[i * cols + j] = Math.min(del, ins, sub);
}
}
return matrix[(rows - 1) * cols + (cols - 1)]!;
}

View file

@ -4,3 +4,9 @@ export {
type CreateSelectorsOptions,
} from "./create";
export { resolveSelectors } from "./resolve";
export { isOrphanedResolution } from "./orphan";
export {
findFuzzyQuoteMatches,
FUZZY_CONFIDENCE_CEILING,
FUZZY_CONFIDENCE_FLOOR,
} from "./fuzzy";

View file

@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { isOrphanedResolution } from "./orphan";
describe("isOrphanedResolution", () => {
it("returns true for unresolved with no candidates", () => {
expect(isOrphanedResolution({
status: "unresolved",
confidence: 0,
candidates: [],
usedSelectorTypes: [],
orphaned: true,
})).toBe(true);
});
it("returns false for stale resolutions", () => {
expect(isOrphanedResolution({
status: "stale",
confidence: 0,
candidates: [],
usedSelectorTypes: [],
orphaned: false,
})).toBe(false);
});
it("returns false when fuzzy produced a candidate", () => {
expect(isOrphanedResolution({
status: "stale",
confidence: 0.2,
candidates: [{ representationId: "rep" }],
usedSelectorTypes: ["TextQuoteSelector"],
orphaned: false,
})).toBe(false);
});
});

10
src/selectors/orphan.ts Normal file
View file

@ -0,0 +1,10 @@
import type { AnchorResolution } from "../types";
/**
* True when no selector could place the anchor at all (distinct from stale,
* which may still offer fuzzy recovery candidates).
*/
export function isOrphanedResolution(resolution: AnchorResolution): boolean {
if (resolution.orphaned !== undefined) return resolution.orphaned;
return resolution.status === "unresolved" && resolution.candidates.length === 0;
}

View file

@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import type { DocumentRepresentation } from "@citation-evidence/engine/shared";
import type { DocumentId, RepresentationId } from "@citation-evidence/engine/shared";
import type { Selector } from "@citation-evidence/engine/shared";
import { resolveSelectors } from "./resolve";
const blockText = "The quick brown fox jumps over the lazy dog.";
const text = `## Section\n\n${blockText}`;
function repr(structureStart = text.indexOf(blockText)): DocumentRepresentation {
return {
id: "rep_dom" as RepresentationId,
documentId: "doc_dom" as DocumentId,
representationType: "markdown-rendered",
contentHash: "test",
canonicalText: text,
structureMap: [
{
globalStart: structureStart,
globalEnd: structureStart + blockText.length,
path: [{ kind: "section", index: 0, label: "Section" }],
containerPath: [0, 1, 0],
},
],
generatedAt: "2026-07-09T00:00:00.000Z",
};
}
describe("resolveSelectors (dom)", () => {
it("resolves DomRangeSelector via structureMap", () => {
const foxStart = text.indexOf("brown fox");
const selectors: Selector[] = [
{
type: "DomRangeSelector",
startPath: [0, 1, 0],
startOffset: foxStart - text.indexOf(blockText),
endPath: [0, 1, 0],
endOffset: foxStart - text.indexOf(blockText) + "brown fox".length,
},
];
const r = resolveSelectors(selectors, repr());
expect(r.status).toBe("resolved");
expect(r.confidence).toBe(0.75);
expect(r.candidates[0]?.textPosition?.start).toBe(foxStart);
});
it("resolves StructuralSelector when offsets shift within the block", () => {
const shifted = "The quick brown fox jumps over a sleepy dog.";
const shiftedDoc = `## Section\n\n${shifted}`;
const blockStart = shiftedDoc.indexOf(shifted);
const selectors: Selector[] = [
{
type: "StructuralSelector",
path: [{ kind: "section", index: 0, label: "Section" }],
startOffset: blockText.indexOf("brown fox"),
endOffset: blockText.indexOf("brown fox") + "brown fox".length,
},
];
const representation: DocumentRepresentation = {
...repr(blockStart),
canonicalText: shiftedDoc,
structureMap: [
{
globalStart: blockStart,
globalEnd: blockStart + shifted.length,
path: [{ kind: "section", index: 0, label: "Section" }],
},
],
};
const r = resolveSelectors(selectors, representation);
expect(r.status).toBe("resolved");
expect(r.confidence).toBe(0.65);
expect(r.candidates[0]?.textPosition?.start).toBe(
representation.canonicalText!.indexOf("brown fox"),
);
});
});

View file

@ -133,5 +133,33 @@ describe("resolveSelectors", () => {
expect(r.status).toBe("unresolved");
expect(r.confidence).toBe(0);
expect(r.candidates).toEqual([]);
expect(r.orphaned).toBe(true);
});
it("returns stale when position is in-range but quote no longer exists", () => {
const r = resolveSelectors(
[
{ type: "TextPositionSelector", start: brownFoxStart, end: brownFoxEnd },
{ type: "TextQuoteSelector", exact: "brown fox" },
],
repr("The quick grey wolf jumps over the lazy dog."),
);
expect(r.status).toBe("stale");
expect(r.confidence).toBe(0);
expect(r.candidates).toEqual([]);
expect(r.orphaned).toBe(false);
expect(r.warnings?.some((w) => /no longer matches/i.test(w))).toBe(true);
});
it("returns stale with fuzzy candidate when quote is lightly edited", () => {
const r = resolveSelectors(
[{ type: "TextQuoteSelector", exact: "brown fox" }],
repr("The quick brown foks jumps over the lazy dog."),
);
expect(r.status).toBe("stale");
expect(r.confidence).toBeGreaterThan(0);
expect(r.confidence).toBeLessThan(0.7);
expect(r.candidates[0]?.textPosition?.start).toBeGreaterThanOrEqual(0);
expect(r.warnings?.some((w) => /fuzzy/i.test(w))).toBe(true);
});
});

View file

@ -1,8 +1,7 @@
/**
* Resolve a `Selector[]` against a `DocumentRepresentation`.
*
* Implements the resolution strategy from `wiki/ArchitectureOverview.md` §7,
* MVP-trimmed:
* Implements the resolution strategy from `wiki/ArchitectureOverview.md` §7:
*
* 1. Try `TextPositionSelector` (cheapest direct slice).
* 2. Verify with `TextQuoteSelector` at that position.
@ -10,30 +9,40 @@
* by prefix/suffix.
* 4. Try `PdfPageTextSelector` (page-local offsets through the OffsetMap).
* 5. Fall back to `PdfRectSelector` for a page+rects-only target.
* 6. Return `unresolved` if nothing above succeeds.
* 6. Try `DomRangeSelector` / `StructuralSelector` for non-paginated docs.
* 7. Try fuzzy quote matching (bounded edit distance).
* 8. Return `stale` when representation changed but quote is meaningful;
* `unresolved` when nothing matches at all.
*
* Fuzzy matching is out of scope here; a later workplan owns it.
*
* Confidence ladder (0..1):
* Confidence ladder (0..1) for exact tiers:
* 1.00 TextPosition + TextQuote agree exactly
* 0.95 TextQuote unique match (no position to cross-check)
* 0.90 TextQuote disambiguated by prefix/suffix
* 0.85 TextPosition only (no quote to cross-check)
* 0.80 PdfPageTextSelector resolved via OffsetMap
* 0.75 DomRangeSelector resolved via structureMap
* 0.70 PdfRectSelector only (page+rects, no text verification)
* 0.65 StructuralSelector resolved via structureMap
*
* Fuzzy floor: 0.150.35 (never reaches exact-tier confidence).
*/
import type { DocumentRepresentation } from "@citation-evidence/engine/shared";
import type {
DomRangeSelector,
PdfPageTextSelector,
PdfRectSelector,
Selector,
SelectorType,
StructuralSelector,
TextPositionSelector,
TextQuoteSelector,
} from "@citation-evidence/engine/shared";
import type { AnchorResolution, ResolvedAnchorTarget } from "../types";
import { resolveDomRangeSpan, resolveStructuralSpan } from "./dom-path";
import { findFuzzyQuoteMatches } from "./fuzzy";
import { isOrphanedResolution } from "./orphan";
export function resolveSelectors(
selectors: readonly Selector[],
@ -41,11 +50,13 @@ export function resolveSelectors(
): AnchorResolution {
const canonicalText = representation.canonicalText ?? "";
const offsetMap = representation.offsetMap ?? [];
const structureMap = representation.structureMap ?? [];
const representationId = representation.id;
const byType = indexByType(selectors);
const used: SelectorType[] = [];
const warnings: string[] = [];
let staleSignal = false;
// 1 & 2. Try TextPositionSelector, verify with TextQuoteSelector.
if (byType.TextPositionSelector && canonicalText.length > 0) {
@ -56,24 +67,62 @@ export function resolveSelectors(
if (quote) {
if (slice === quote.exact) {
used.push("TextPositionSelector", "TextQuoteSelector");
return resolved(
{ representationId, textPosition: { start: pos.start, end: pos.end }, ...pageFor(pos, offsetMap) },
1.0,
used,
warnings,
return finish(
resolved(
{
representationId,
textPosition: { start: pos.start, end: pos.end },
...pageFor(pos, offsetMap),
},
1.0,
used,
warnings,
),
);
}
warnings.push(
"TextPositionSelector slice did not match TextQuoteSelector.exact; falling back to quote search.",
);
const quoteResult = resolveByQuote(canonicalText, quote);
if (quoteResult) {
used.push("TextQuoteSelector");
return finish(
resolved(
{
representationId,
textPosition: {
start: quoteResult.offset,
end: quoteResult.offset + quote.exact.length,
},
...pageFor(
{ start: quoteResult.offset, end: quoteResult.offset + quote.exact.length },
offsetMap,
),
},
quoteResult.confidence,
used,
warnings,
quoteResult.status,
),
);
}
staleSignal = true;
warnings.push(
"Stored TextPositionSelector no longer matches canonical text and TextQuoteSelector.exact was not found.",
);
} else {
// Position with no quote to verify — accept at lower confidence.
used.push("TextPositionSelector");
return resolved(
{ representationId, textPosition: { start: pos.start, end: pos.end }, ...pageFor(pos, offsetMap) },
0.85,
used,
warnings,
return finish(
resolved(
{
representationId,
textPosition: { start: pos.start, end: pos.end },
...pageFor(pos, offsetMap),
},
0.85,
used,
warnings,
),
);
}
}
@ -84,16 +133,27 @@ export function resolveSelectors(
const quoteResult = resolveByQuote(canonicalText, byType.TextQuoteSelector);
if (quoteResult) {
used.push("TextQuoteSelector");
return resolved(
{
representationId,
textPosition: { start: quoteResult.offset, end: quoteResult.offset + byType.TextQuoteSelector.exact.length },
...pageFor({ start: quoteResult.offset, end: quoteResult.offset + byType.TextQuoteSelector.exact.length }, offsetMap),
},
quoteResult.confidence,
used,
warnings,
quoteResult.status,
return finish(
resolved(
{
representationId,
textPosition: {
start: quoteResult.offset,
end: quoteResult.offset + byType.TextQuoteSelector.exact.length,
},
...pageFor(
{
start: quoteResult.offset,
end: quoteResult.offset + byType.TextQuoteSelector.exact.length,
},
offsetMap,
),
},
quoteResult.confidence,
used,
warnings,
quoteResult.status,
),
);
}
}
@ -106,15 +166,17 @@ export function resolveSelectors(
const globalStart = range.globalStart + pageText.start;
const globalEnd = range.globalStart + pageText.end;
used.push("PdfPageTextSelector");
return resolved(
{
representationId,
page: pageText.page,
textPosition: { start: globalStart, end: globalEnd },
},
0.8,
used,
warnings,
return finish(
resolved(
{
representationId,
page: pageText.page,
textPosition: { start: globalStart, end: globalEnd },
},
0.8,
used,
warnings,
),
);
}
}
@ -123,15 +185,113 @@ export function resolveSelectors(
if (byType.PdfRectSelector) {
const rect = byType.PdfRectSelector;
used.push("PdfRectSelector");
return resolved(
{ representationId, page: rect.page, rects: rect.rects },
0.7,
used,
warnings,
return finish(
resolved(
{ representationId, page: rect.page, rects: rect.rects },
0.7,
used,
warnings,
),
);
}
return unresolved(warnings);
// 6a. DomRangeSelector for non-paginated representations.
if (byType.DomRangeSelector && structureMap.length > 0) {
const span = resolveDomRangeSpan(structureMap, byType.DomRangeSelector);
if (span) {
used.push("DomRangeSelector");
return finish(
resolved(
{
representationId,
textPosition: span,
},
0.75,
used,
warnings,
),
);
}
}
// 6b. StructuralSelector fallback.
if (byType.StructuralSelector && structureMap.length > 0) {
const span = resolveStructuralSpan(structureMap, byType.StructuralSelector);
if (span) {
used.push("StructuralSelector");
return finish(
resolved(
{
representationId,
textPosition: span,
},
0.65,
used,
warnings,
),
);
}
}
// 7. Fuzzy quote matching.
if (byType.TextQuoteSelector && canonicalText.length > 0) {
const fuzzyMatches = findFuzzyQuoteMatches(canonicalText, byType.TextQuoteSelector);
if (fuzzyMatches.length > 0) {
const bestDistance = fuzzyMatches[0]!.distance;
const bestCandidates = fuzzyMatches.filter((m) => m.distance === bestDistance);
const distinctOffsets = new Set(bestCandidates.map((m) => m.offset));
const match = bestCandidates[0]!;
used.push("TextQuoteSelector");
if (distinctOffsets.size === 1) {
warnings.push("Resolved via fuzzy quote matching; representation may have changed.");
return finish(
resolved(
{
representationId,
textPosition: {
start: match.offset,
end: match.offset + match.matchedText.length,
},
...pageFor(
{ start: match.offset, end: match.offset + match.matchedText.length },
offsetMap,
),
},
match.confidence,
used,
warnings,
"stale",
),
);
}
warnings.push("Multiple fuzzy quote candidates; manual confirmation recommended.");
return finish(
resolved(
{
representationId,
textPosition: {
start: match.offset,
end: match.offset + match.matchedText.length,
},
...pageFor(
{ start: match.offset, end: match.offset + match.matchedText.length },
offsetMap,
),
},
match.confidence,
used,
warnings,
"ambiguous",
),
);
}
}
if (staleSignal) {
return finish(stale(warnings));
}
return finish(unresolved(warnings));
}
interface QuoteResolutionResult {
@ -146,7 +306,6 @@ function resolveByQuote(canonicalText: string, quote: TextQuoteSelector): QuoteR
if (positions.length === 1) {
return { offset: positions[0]!, confidence: 0.95, status: "resolved" };
}
// Multiple matches — try to disambiguate by prefix/suffix.
const filtered = positions.filter((p) => prefixSuffixMatches(canonicalText, p, quote));
if (filtered.length === 1) {
return { offset: filtered[0]!, confidence: 0.9, status: "resolved" };
@ -154,7 +313,6 @@ function resolveByQuote(canonicalText: string, quote: TextQuoteSelector): QuoteR
if (filtered.length > 1) {
return { offset: filtered[0]!, confidence: 0.5, status: "ambiguous" };
}
// No prefix/suffix info or no matches with context — return ambiguous on first.
return { offset: positions[0]!, confidence: 0.5, status: "ambiguous" };
}
@ -183,6 +341,8 @@ interface SelectorIndex {
TextPositionSelector?: TextPositionSelector;
PdfRectSelector?: PdfRectSelector;
PdfPageTextSelector?: PdfPageTextSelector;
DomRangeSelector?: DomRangeSelector;
StructuralSelector?: StructuralSelector;
}
function indexByType(selectors: readonly Selector[]): SelectorIndex {
@ -201,6 +361,12 @@ function indexByType(selectors: readonly Selector[]): SelectorIndex {
case "PdfPageTextSelector":
idx.PdfPageTextSelector = s;
break;
case "DomRangeSelector":
idx.DomRangeSelector = s;
break;
case "StructuralSelector":
idx.StructuralSelector = s;
break;
}
}
return idx;
@ -238,23 +404,46 @@ function resolved(
confidence: number,
used: readonly SelectorType[],
warnings: readonly string[],
status: "resolved" | "ambiguous" = "resolved",
status: "resolved" | "ambiguous" | "stale" = "resolved",
): AnchorResolution {
return {
status,
confidence,
candidates: [target],
usedSelectorTypes: used,
orphaned: false,
...(warnings.length > 0 ? { warnings } : {}),
};
}
function stale(warnings: readonly string[]): AnchorResolution {
return {
status: "stale",
confidence: 0,
candidates: [],
usedSelectorTypes: [],
orphaned: false,
warnings: [...warnings],
};
}
function unresolved(warnings: readonly string[]): AnchorResolution {
return {
status: "unresolved",
confidence: 0,
candidates: [],
usedSelectorTypes: [],
orphaned: true,
...(warnings.length > 0 ? { warnings } : {}),
};
}
function finish(resolution: AnchorResolution): AnchorResolution {
if (resolution.orphaned === undefined) {
return {
...resolution,
orphaned: isOrphanedResolution(resolution),
};
}
return resolution;
}

View file

@ -12,7 +12,11 @@
import type { Document, DocumentRepresentation } from "@citation-evidence/engine/shared";
import type { Selector } from "@citation-evidence/engine/shared";
import type { AnnotationResolutionStatus } from "@citation-evidence/engine/shared";
import type { NormalizedRect } from "@citation-evidence/engine/shared";
import type {
DomNodePath,
NormalizedRect,
StructuralPathSegment,
} from "@citation-evidence/engine/shared";
/**
* The raw selection captured from a viewer adapter an opaque payload that
@ -36,8 +40,17 @@ export interface PdfSelectionCapture {
readonly boundingRect?: NormalizedRect;
}
/** Reserved for the HTML/Markdown adapter. Not implementable in MVP. */
export type DomSelectionCapture = never;
/** Selection captured from a rendered HTML/Markdown viewer. */
export interface DomSelectionCapture {
readonly kind: "dom";
/** Verbatim selected text, before canonical normalisation. */
readonly text: string;
readonly startPath: DomNodePath;
readonly startOffset: number;
readonly endPath: DomNodePath;
readonly endOffset: number;
readonly structuralPath?: readonly StructuralPathSegment[];
}
/**
* A passage located inside a representation, ready to be scrolled to and
@ -65,6 +78,11 @@ export interface AnchorResolution {
/** Names of the selector kinds that produced a usable candidate. */
readonly usedSelectorTypes: readonly string[];
readonly warnings?: readonly string[];
/**
* True when no selector could place the anchor at all. Distinct from `stale`,
* which signals representation drift and may still return fuzzy candidates.
*/
readonly orphaned?: boolean;
}
export interface HighlightRenderOptions {

View file

@ -4,11 +4,11 @@ type: workplan
title: "Anchor resolution hardening: stale/orphan semantics, fuzzy re-anchoring, production PDF adapter"
domain: infotech
repo: evidence-anchor
status: proposed
status: finished
owner: codex
topic_slug: citation_evidence_mvp
created: "2026-07-08"
updated: "2026-07-08"
updated: "2026-07-09"
spec_refs:
- INTENT.md
- SCOPE.md
@ -48,7 +48,7 @@ Current extracted behavior (`src/selectors/resolve.ts`):
```task
id: EANCH-WP-0002-T01
status: todo
status: done
priority: high
state_hub_task_id: "940b03ba-4b2f-4d1a-b7bc-72813817549d"
```
@ -63,7 +63,7 @@ quote/context on the result. Add unit tests covering stale vs. unresolved.
```task
id: EANCH-WP-0002-T02
status: todo
status: done
priority: medium
depends_on: [T01]
state_hub_task_id: "f75b94cd-bfb8-47f9-bd89-0d605f925290"
@ -78,7 +78,7 @@ enum unchanged unless a contract change is agreed in the umbrella wiki first.
```task
id: EANCH-WP-0002-T03
status: todo
status: done
priority: high
depends_on: [T01]
state_hub_task_id: "8548fa44-5bb3-40cf-b8f8-e745deae7dda"
@ -95,7 +95,7 @@ floor.
```task
id: EANCH-WP-0002-T04
status: todo
status: done
priority: medium
depends_on: [T03]
state_hub_task_id: "6a7163f5-39ca-48fa-8941-8459c563198f"
@ -110,7 +110,7 @@ umbrella green through the change. Update `evidence-anchor/pdf` exports and docs
```task
id: EANCH-WP-0002-T05
status: todo
status: done
priority: high
depends_on: [T02, T04]
state_hub_task_id: "1ea0885a-c541-438f-a4fd-b8be0773b373"
@ -118,3 +118,8 @@ state_hub_task_id: "1ea0885a-c541-438f-a4fd-b8be0773b373"
`pnpm test`/`typecheck`/`lint` green here; umbrella typecheck/test/build green
after any contract-visible change; `fix-consistency` clean; progress note.
**Verification (2026-07-09):**
- `evidence-anchor`: 42 tests (9 files), typecheck + lint clean.
- `citation-evidence`: typecheck clean, 51 integration tests passed.

View file

@ -4,11 +4,11 @@ type: workplan
title: "Non-PDF selectors: HTML/Markdown DOM range + structural anchoring"
domain: infotech
repo: evidence-anchor
status: proposed
status: finished
owner: codex
topic_slug: citation_evidence_mvp
created: "2026-07-08"
updated: "2026-07-08"
updated: "2026-07-09"
spec_refs:
- INTENT.md
- SCOPE.md
@ -47,7 +47,7 @@ enum — coordinate contract changes in the umbrella wiki first.
```task
id: EANCH-WP-0003-T01
status: todo
status: done
priority: high
state_hub_task_id: "de7df94f-2ddf-4920-949e-6e693131a9de"
```
@ -61,7 +61,7 @@ Land this in the engine + `SharedContracts.md` before writing anchor behavior.
```task
id: EANCH-WP-0003-T02
status: todo
status: done
priority: high
depends_on: [T01]
state_hub_task_id: "aa1bc68d-c15a-41e0-813d-c4ccec4914c9"
@ -75,7 +75,7 @@ DOM selection. Add unit tests mirroring the PDF create tests.
```task
id: EANCH-WP-0003-T03
status: todo
status: done
priority: high
depends_on: [T02]
state_hub_task_id: "2bdedc91-1ed2-41ed-b4a0-a9a0d7a6b4b0"
@ -90,7 +90,7 @@ re-render stability (structural fallback when offsets shift).
```task
id: EANCH-WP-0003-T04
status: todo
status: done
priority: medium
depends_on: [T03]
state_hub_task_id: "286267fe-d348-4494-bbe2-dd7818bcadbd"
@ -105,7 +105,7 @@ to that boundary.
```task
id: EANCH-WP-0003-T05
status: todo
status: done
priority: high
depends_on: [T04]
state_hub_task_id: "9ff39c83-eca8-46a1-9b28-8a728ddf220d"
@ -114,3 +114,8 @@ state_hub_task_id: "9ff39c83-eca8-46a1-9b28-8a728ddf220d"
Package green (`pnpm test`/`typecheck`/`lint`); engine contract change verified
in `citation-engine`; any umbrella consumer green; `fix-consistency` clean;
progress note.
**Verification (2026-07-09):**
- `citation-engine`: DomRange/Structural types + StructureMap promoted; 89 tests passed.
- `evidence-anchor`: DOM create/resolve + `evidence-anchor/dom` export; 42 tests passed.