Implement EANCH-WP-0002 and EANCH-WP-0003 anchor hardening and DOM selectors
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:
parent
e4582b52fa
commit
a1af0c6a45
22 changed files with 1118 additions and 105 deletions
|
|
@ -20,7 +20,8 @@
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./selectors": "./src/selectors/index.ts",
|
"./selectors": "./src/selectors/index.ts",
|
||||||
"./types": "./src/types.ts",
|
"./types": "./src/types.ts",
|
||||||
"./pdf": "./src/pdf/index.ts"
|
"./pdf": "./src/pdf/index.ts",
|
||||||
|
"./dom": "./src/dom/index.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
|
|
||||||
30
src/dom/dom-path.test.ts
Normal file
30
src/dom/dom-path.test.ts
Normal 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
58
src/dom/dom-path.ts
Normal 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 } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
156
src/dom/html-viewer-adapter.tsx
Normal file
156
src/dom/html-viewer-adapter.tsx
Normal 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
11
src/dom/index.ts
Normal 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";
|
||||||
|
|
@ -22,8 +22,11 @@ export {
|
||||||
|
|
||||||
// PDF adapter + helpers (re-exported from the src/pdf/ boundary).
|
// PDF adapter + helpers (re-exported from the src/pdf/ boundary).
|
||||||
export {
|
export {
|
||||||
|
PdfViewerAdapter,
|
||||||
PdfSpikeViewer,
|
PdfSpikeViewer,
|
||||||
getHighlightClientRects,
|
getHighlightClientRects,
|
||||||
|
type PdfViewerAdapterProps,
|
||||||
|
type PdfViewerDebugOptions,
|
||||||
selectorsFromPdfCapture,
|
selectorsFromPdfCapture,
|
||||||
findPdfRectSelector,
|
findPdfRectSelector,
|
||||||
findTextQuoteSelector,
|
findTextQuoteSelector,
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,13 @@ export {
|
||||||
type ScrollToHighlightState,
|
type ScrollToHighlightState,
|
||||||
} from "./scroll-job";
|
} from "./scroll-job";
|
||||||
|
|
||||||
// Concrete PDF viewer adapter (spike) + its contract surface.
|
// Production PDF viewer adapter + its contract surface.
|
||||||
export {
|
export {
|
||||||
|
PdfViewerAdapter,
|
||||||
PdfSpikeViewer,
|
PdfSpikeViewer,
|
||||||
getHighlightClientRects,
|
getHighlightClientRects,
|
||||||
|
type PdfViewerAdapterProps,
|
||||||
|
type PdfViewerDebugOptions,
|
||||||
type PdfSpikeViewerProps,
|
type PdfSpikeViewerProps,
|
||||||
type StoredAnnotation,
|
type StoredAnnotation,
|
||||||
} from "./pdf-viewer-adapter-spike";
|
} from "./pdf-viewer-adapter-spike";
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,12 @@ import "react-pdf-highlighter-plus/style/pdf_viewer.css";
|
||||||
import "./highlight-styles.css";
|
import "./highlight-styles.css";
|
||||||
import "./debug-textlayer.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 type { AnchorResolution, PdfSelectionCapture, ResolvedAnchorTarget } from "../types";
|
||||||
import { findPdfRectSelector, selectorsFromPdfCapture, unionRect } from "./pdf-selector-math";
|
import { findPdfRectSelector, selectorsFromPdfCapture, unionRect } from "./pdf-selector-math";
|
||||||
import { runScrollToHighlightJob } from "./scroll-job";
|
import { runScrollToHighlightJob } from "./scroll-job";
|
||||||
|
|
@ -63,7 +68,29 @@ function highlightFromSelectors(
|
||||||
id: string,
|
id: string,
|
||||||
text: string,
|
text: string,
|
||||||
selectors: readonly Selector[],
|
selectors: readonly Selector[],
|
||||||
|
representation?: DocumentRepresentation,
|
||||||
): Highlight | null {
|
): 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);
|
const rectSel = findPdfRectSelector(selectors);
|
||||||
if (!rectSel) return null;
|
if (!rectSel) return null;
|
||||||
const boundingRect = unionRect(rectSel.rects);
|
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);
|
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). */
|
/** URL of the PDF to load (served by Vite dev server). */
|
||||||
readonly pdfUrl: string;
|
readonly pdfUrl: string;
|
||||||
|
/** Document representation for selector resolution (enables text-based reload). */
|
||||||
|
readonly representation?: DocumentRepresentation;
|
||||||
/** Previously-saved selector sets to restore on mount. */
|
/** Previously-saved selector sets to restore on mount. */
|
||||||
readonly storedAnnotations: readonly StoredAnnotation[];
|
readonly storedAnnotations: readonly StoredAnnotation[];
|
||||||
/** Called when the user produces a new selection. */
|
/** Called when the user produces a new selection. */
|
||||||
|
|
@ -213,23 +250,23 @@ export interface PdfSpikeViewerProps {
|
||||||
* The receiver typically activates the matching evidence item.
|
* The receiver typically activates the matching evidence item.
|
||||||
*/
|
*/
|
||||||
onHighlightClicked?(annotationId: string): void;
|
onHighlightClicked?(annotationId: string): void;
|
||||||
/**
|
/** Spike-only debug flags — not part of the production adapter contract. */
|
||||||
* When true, paint the PDF text-layer spans in light grey so it's
|
readonly debug?: PdfViewerDebugOptions;
|
||||||
* obvious which glyphs have a selectable text overlay and which are
|
/** @deprecated Pass via `debug.debugTextLayer` */
|
||||||
* image-only. Also logs every onSelection event to the console.
|
|
||||||
*/
|
|
||||||
readonly debugTextLayer?: boolean;
|
readonly debugTextLayer?: boolean;
|
||||||
/**
|
/** @deprecated Pass via `debug.hideCanvas` */
|
||||||
* 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?").
|
|
||||||
*/
|
|
||||||
readonly hideCanvas?: boolean;
|
readonly hideCanvas?: boolean;
|
||||||
|
/** @deprecated Pass via `debug.hideTextLayer` */
|
||||||
readonly hideTextLayer?: boolean;
|
readonly hideTextLayer?: boolean;
|
||||||
|
/** @deprecated Pass via `debug.hideAnnotationLayer` */
|
||||||
readonly hideAnnotationLayer?: boolean;
|
readonly hideAnnotationLayer?: boolean;
|
||||||
|
/** @deprecated Pass via `debug.hideXfaLayer` */
|
||||||
readonly hideXfaLayer?: boolean;
|
readonly hideXfaLayer?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use `PdfViewerAdapterProps` */
|
||||||
|
export type PdfSpikeViewerProps = PdfViewerAdapterProps;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nudge the PDF scroll container so `highlight` sits vertically centred.
|
* Nudge the PDF scroll container so `highlight` sits vertically centred.
|
||||||
* Best-effort: depends on highlight layer DOM being present after scroll.
|
* 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
|
* - reconstructs and renders `storedAnnotations` immediately on load
|
||||||
* - scrolls to `scrollToAnnotationId` if its highlight can be reconstructed
|
* - scrolls to `scrollToAnnotationId` if its highlight can be reconstructed
|
||||||
*/
|
*/
|
||||||
export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
|
export function PdfViewerAdapter(props: PdfViewerAdapterProps) {
|
||||||
const {
|
const {
|
||||||
pdfUrl,
|
pdfUrl,
|
||||||
|
representation,
|
||||||
storedAnnotations,
|
storedAnnotations,
|
||||||
onSelectionCaptured,
|
onSelectionCaptured,
|
||||||
scrollToAnnotationId,
|
scrollToAnnotationId,
|
||||||
scrollRequestKey,
|
scrollRequestKey,
|
||||||
activeAnnotationId,
|
activeAnnotationId,
|
||||||
onHighlightClicked,
|
onHighlightClicked,
|
||||||
debugTextLayer,
|
debug,
|
||||||
hideCanvas,
|
debugTextLayer: debugTextLayerProp,
|
||||||
hideTextLayer,
|
hideCanvas: hideCanvasProp,
|
||||||
hideAnnotationLayer,
|
hideTextLayer: hideTextLayerProp,
|
||||||
hideXfaLayer,
|
hideAnnotationLayer: hideAnnotationLayerProp,
|
||||||
|
hideXfaLayer: hideXfaLayerProp,
|
||||||
} = props;
|
} = 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);
|
const onHighlightClickedRef = useRef(onHighlightClicked);
|
||||||
onHighlightClickedRef.current = onHighlightClicked;
|
onHighlightClickedRef.current = onHighlightClicked;
|
||||||
const handleHighlightClicked = useCallback((annotationId: string) => {
|
const handleHighlightClicked = useCallback((annotationId: string) => {
|
||||||
|
|
@ -318,7 +362,7 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
|
||||||
const out: Highlight[] = [];
|
const out: Highlight[] = [];
|
||||||
const skipped: { id: string; reason: string }[] = [];
|
const skipped: { id: string; reason: string }[] = [];
|
||||||
for (const a of storedAnnotations) {
|
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);
|
if (h) out.push(h);
|
||||||
else skipped.push({ id: a.id, reason: "no PdfRectSelector / empty boundingRect" });
|
else skipped.push({ id: a.id, reason: "no PdfRectSelector / empty boundingRect" });
|
||||||
}
|
}
|
||||||
|
|
@ -336,7 +380,7 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}, [storedAnnotations, debugTextLayer]);
|
}, [storedAnnotations, representation, debugTextLayer]);
|
||||||
|
|
||||||
const highlightsRef = useRef(highlights);
|
const highlightsRef = useRef(highlights);
|
||||||
highlightsRef.current = 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.
|
// Re-export the §5 contract surface so callers see anchor as one entry point.
|
||||||
export type { AnchorResolution, ResolvedAnchorTarget, PdfSelectionCapture };
|
export type { AnchorResolution, ResolvedAnchorTarget, PdfSelectionCapture };
|
||||||
|
|
|
||||||
59
src/selectors/create.dom.test.ts
Normal file
59
src/selectors/create.dom.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -5,25 +5,21 @@
|
||||||
* from `wiki/SharedContracts.md` §3 (selector redundancy) and the create
|
* from `wiki/SharedContracts.md` §3 (selector redundancy) and the create
|
||||||
* half of the `AnchorAdapter` contract in
|
* half of the `AnchorAdapter` contract in
|
||||||
* `wiki/ArchitectureOverview.md` §3.3.
|
* `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 type { DocumentRepresentation } from "@citation-evidence/engine/shared";
|
||||||
import { normalize } from "@citation-evidence/engine/shared";
|
import { normalize } from "@citation-evidence/engine/shared";
|
||||||
import type {
|
import type {
|
||||||
|
DomRangeSelector,
|
||||||
PdfPageTextSelector,
|
PdfPageTextSelector,
|
||||||
PdfRectSelector,
|
PdfRectSelector,
|
||||||
Selector,
|
Selector,
|
||||||
|
StructuralSelector,
|
||||||
TextPositionSelector,
|
TextPositionSelector,
|
||||||
TextQuoteSelector,
|
TextQuoteSelector,
|
||||||
} from "@citation-evidence/engine/shared";
|
} 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. */
|
/** Default characters of prefix/suffix context stored on TextQuoteSelector. */
|
||||||
export const DEFAULT_CONTEXT_CHARS = 32;
|
export const DEFAULT_CONTEXT_CHARS = 32;
|
||||||
|
|
@ -37,8 +33,9 @@ export function createSelectors(
|
||||||
representation: DocumentRepresentation,
|
representation: DocumentRepresentation,
|
||||||
options: CreateSelectorsOptions = {},
|
options: CreateSelectorsOptions = {},
|
||||||
): Selector[] {
|
): Selector[] {
|
||||||
// `SelectionCapture` is a discriminated union. The DOM branch is `never`
|
if (capture.kind === "dom") {
|
||||||
// in MVP, so the only runtime shape is `PdfSelectionCapture`.
|
return createSelectorsFromDomCapture(capture, representation, options);
|
||||||
|
}
|
||||||
return createSelectorsFromPdfCapture(capture, representation, options);
|
return createSelectorsFromPdfCapture(capture, representation, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,14 +53,9 @@ function createSelectorsFromPdfCapture(
|
||||||
? findAllOccurrences(canonicalText, normalizedQuote)
|
? 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 pageRange = representation.offsetMap?.find((r) => r.page === capture.page);
|
||||||
const matchOffset = pickMatch(positions, pageRange);
|
const matchOffset = pickMatch(positions, pageRange);
|
||||||
|
|
||||||
// 1. TextQuoteSelector — always included.
|
|
||||||
if (normalizedQuote.length > 0) {
|
if (normalizedQuote.length > 0) {
|
||||||
const quote = matchOffset !== null
|
const quote = matchOffset !== null
|
||||||
? buildQuoteSelectorWithContext(canonicalText, matchOffset, normalizedQuote, contextChars)
|
? buildQuoteSelectorWithContext(canonicalText, matchOffset, normalizedQuote, contextChars)
|
||||||
|
|
@ -71,7 +63,6 @@ function createSelectorsFromPdfCapture(
|
||||||
out.push(quote);
|
out.push(quote);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. TextPositionSelector — only when we have a unique-enough match.
|
|
||||||
if (matchOffset !== null) {
|
if (matchOffset !== null) {
|
||||||
const pos: TextPositionSelector = {
|
const pos: TextPositionSelector = {
|
||||||
type: "TextPositionSelector",
|
type: "TextPositionSelector",
|
||||||
|
|
@ -81,7 +72,6 @@ function createSelectorsFromPdfCapture(
|
||||||
out.push(pos);
|
out.push(pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. PdfRectSelector — straight from the capture; viewer-coordinate truth.
|
|
||||||
if (capture.rects.length > 0) {
|
if (capture.rects.length > 0) {
|
||||||
const rect: PdfRectSelector = {
|
const rect: PdfRectSelector = {
|
||||||
type: "PdfRectSelector",
|
type: "PdfRectSelector",
|
||||||
|
|
@ -91,8 +81,6 @@ function createSelectorsFromPdfCapture(
|
||||||
out.push(rect);
|
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 !== null && pageRange) {
|
||||||
if (matchOffset >= pageRange.globalStart && matchOffset + normalizedQuote.length <= pageRange.globalEnd) {
|
if (matchOffset >= pageRange.globalStart && matchOffset + normalizedQuote.length <= pageRange.globalEnd) {
|
||||||
const pageText: PdfPageTextSelector = {
|
const pageText: PdfPageTextSelector = {
|
||||||
|
|
@ -108,6 +96,70 @@ function createSelectorsFromPdfCapture(
|
||||||
return out;
|
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[] {
|
function findAllOccurrences(haystack: string, needle: string): number[] {
|
||||||
if (needle.length === 0) return [];
|
if (needle.length === 0) return [];
|
||||||
const out: number[] = [];
|
const out: number[] = [];
|
||||||
|
|
@ -133,8 +185,6 @@ function pickMatch(
|
||||||
);
|
);
|
||||||
if (onPage !== undefined) return onPage;
|
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]!;
|
return positions[0]!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
89
src/selectors/dom-path.ts
Normal file
89
src/selectors/dom-path.ts
Normal 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 };
|
||||||
|
}
|
||||||
21
src/selectors/fuzzy.test.ts
Normal file
21
src/selectors/fuzzy.test.ts
Normal 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
113
src/selectors/fuzzy.ts
Normal 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)]!;
|
||||||
|
}
|
||||||
|
|
@ -4,3 +4,9 @@ export {
|
||||||
type CreateSelectorsOptions,
|
type CreateSelectorsOptions,
|
||||||
} from "./create";
|
} from "./create";
|
||||||
export { resolveSelectors } from "./resolve";
|
export { resolveSelectors } from "./resolve";
|
||||||
|
export { isOrphanedResolution } from "./orphan";
|
||||||
|
export {
|
||||||
|
findFuzzyQuoteMatches,
|
||||||
|
FUZZY_CONFIDENCE_CEILING,
|
||||||
|
FUZZY_CONFIDENCE_FLOOR,
|
||||||
|
} from "./fuzzy";
|
||||||
|
|
|
||||||
34
src/selectors/orphan.test.ts
Normal file
34
src/selectors/orphan.test.ts
Normal 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
10
src/selectors/orphan.ts
Normal 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;
|
||||||
|
}
|
||||||
77
src/selectors/resolve.dom.test.ts
Normal file
77
src/selectors/resolve.dom.test.ts
Normal 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"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -133,5 +133,33 @@ describe("resolveSelectors", () => {
|
||||||
expect(r.status).toBe("unresolved");
|
expect(r.status).toBe("unresolved");
|
||||||
expect(r.confidence).toBe(0);
|
expect(r.confidence).toBe(0);
|
||||||
expect(r.candidates).toEqual([]);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* Resolve a `Selector[]` against a `DocumentRepresentation`.
|
* Resolve a `Selector[]` against a `DocumentRepresentation`.
|
||||||
*
|
*
|
||||||
* Implements the resolution strategy from `wiki/ArchitectureOverview.md` §7,
|
* Implements the resolution strategy from `wiki/ArchitectureOverview.md` §7:
|
||||||
* MVP-trimmed:
|
|
||||||
*
|
*
|
||||||
* 1. Try `TextPositionSelector` (cheapest — direct slice).
|
* 1. Try `TextPositionSelector` (cheapest — direct slice).
|
||||||
* 2. Verify with `TextQuoteSelector` at that position.
|
* 2. Verify with `TextQuoteSelector` at that position.
|
||||||
|
|
@ -10,30 +9,40 @@
|
||||||
* by prefix/suffix.
|
* by prefix/suffix.
|
||||||
* 4. Try `PdfPageTextSelector` (page-local offsets through the OffsetMap).
|
* 4. Try `PdfPageTextSelector` (page-local offsets through the OffsetMap).
|
||||||
* 5. Fall back to `PdfRectSelector` for a page+rects-only target.
|
* 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) for exact tiers:
|
||||||
*
|
|
||||||
* Confidence ladder (0..1):
|
|
||||||
* 1.00 — TextPosition + TextQuote agree exactly
|
* 1.00 — TextPosition + TextQuote agree exactly
|
||||||
* 0.95 — TextQuote unique match (no position to cross-check)
|
* 0.95 — TextQuote unique match (no position to cross-check)
|
||||||
* 0.90 — TextQuote disambiguated by prefix/suffix
|
* 0.90 — TextQuote disambiguated by prefix/suffix
|
||||||
* 0.85 — TextPosition only (no quote to cross-check)
|
* 0.85 — TextPosition only (no quote to cross-check)
|
||||||
* 0.80 — PdfPageTextSelector resolved via OffsetMap
|
* 0.80 — PdfPageTextSelector resolved via OffsetMap
|
||||||
|
* 0.75 — DomRangeSelector resolved via structureMap
|
||||||
* 0.70 — PdfRectSelector only (page+rects, no text verification)
|
* 0.70 — PdfRectSelector only (page+rects, no text verification)
|
||||||
|
* 0.65 — StructuralSelector resolved via structureMap
|
||||||
|
*
|
||||||
|
* Fuzzy floor: 0.15–0.35 (never reaches exact-tier confidence).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { DocumentRepresentation } from "@citation-evidence/engine/shared";
|
import type { DocumentRepresentation } from "@citation-evidence/engine/shared";
|
||||||
import type {
|
import type {
|
||||||
|
DomRangeSelector,
|
||||||
PdfPageTextSelector,
|
PdfPageTextSelector,
|
||||||
PdfRectSelector,
|
PdfRectSelector,
|
||||||
Selector,
|
Selector,
|
||||||
SelectorType,
|
SelectorType,
|
||||||
|
StructuralSelector,
|
||||||
TextPositionSelector,
|
TextPositionSelector,
|
||||||
TextQuoteSelector,
|
TextQuoteSelector,
|
||||||
} from "@citation-evidence/engine/shared";
|
} from "@citation-evidence/engine/shared";
|
||||||
|
|
||||||
import type { AnchorResolution, ResolvedAnchorTarget } from "../types";
|
import type { AnchorResolution, ResolvedAnchorTarget } from "../types";
|
||||||
|
import { resolveDomRangeSpan, resolveStructuralSpan } from "./dom-path";
|
||||||
|
import { findFuzzyQuoteMatches } from "./fuzzy";
|
||||||
|
import { isOrphanedResolution } from "./orphan";
|
||||||
|
|
||||||
export function resolveSelectors(
|
export function resolveSelectors(
|
||||||
selectors: readonly Selector[],
|
selectors: readonly Selector[],
|
||||||
|
|
@ -41,11 +50,13 @@ export function resolveSelectors(
|
||||||
): AnchorResolution {
|
): AnchorResolution {
|
||||||
const canonicalText = representation.canonicalText ?? "";
|
const canonicalText = representation.canonicalText ?? "";
|
||||||
const offsetMap = representation.offsetMap ?? [];
|
const offsetMap = representation.offsetMap ?? [];
|
||||||
|
const structureMap = representation.structureMap ?? [];
|
||||||
const representationId = representation.id;
|
const representationId = representation.id;
|
||||||
|
|
||||||
const byType = indexByType(selectors);
|
const byType = indexByType(selectors);
|
||||||
const used: SelectorType[] = [];
|
const used: SelectorType[] = [];
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
let staleSignal = false;
|
||||||
|
|
||||||
// 1 & 2. Try TextPositionSelector, verify with TextQuoteSelector.
|
// 1 & 2. Try TextPositionSelector, verify with TextQuoteSelector.
|
||||||
if (byType.TextPositionSelector && canonicalText.length > 0) {
|
if (byType.TextPositionSelector && canonicalText.length > 0) {
|
||||||
|
|
@ -56,24 +67,62 @@ export function resolveSelectors(
|
||||||
if (quote) {
|
if (quote) {
|
||||||
if (slice === quote.exact) {
|
if (slice === quote.exact) {
|
||||||
used.push("TextPositionSelector", "TextQuoteSelector");
|
used.push("TextPositionSelector", "TextQuoteSelector");
|
||||||
return resolved(
|
return finish(
|
||||||
{ representationId, textPosition: { start: pos.start, end: pos.end }, ...pageFor(pos, offsetMap) },
|
resolved(
|
||||||
1.0,
|
{
|
||||||
used,
|
representationId,
|
||||||
warnings,
|
textPosition: { start: pos.start, end: pos.end },
|
||||||
|
...pageFor(pos, offsetMap),
|
||||||
|
},
|
||||||
|
1.0,
|
||||||
|
used,
|
||||||
|
warnings,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
warnings.push(
|
warnings.push(
|
||||||
"TextPositionSelector slice did not match TextQuoteSelector.exact; falling back to quote search.",
|
"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 {
|
} else {
|
||||||
// Position with no quote to verify — accept at lower confidence.
|
|
||||||
used.push("TextPositionSelector");
|
used.push("TextPositionSelector");
|
||||||
return resolved(
|
return finish(
|
||||||
{ representationId, textPosition: { start: pos.start, end: pos.end }, ...pageFor(pos, offsetMap) },
|
resolved(
|
||||||
0.85,
|
{
|
||||||
used,
|
representationId,
|
||||||
warnings,
|
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);
|
const quoteResult = resolveByQuote(canonicalText, byType.TextQuoteSelector);
|
||||||
if (quoteResult) {
|
if (quoteResult) {
|
||||||
used.push("TextQuoteSelector");
|
used.push("TextQuoteSelector");
|
||||||
return resolved(
|
return finish(
|
||||||
{
|
resolved(
|
||||||
representationId,
|
{
|
||||||
textPosition: { start: quoteResult.offset, end: quoteResult.offset + byType.TextQuoteSelector.exact.length },
|
representationId,
|
||||||
...pageFor({ start: quoteResult.offset, end: quoteResult.offset + byType.TextQuoteSelector.exact.length }, offsetMap),
|
textPosition: {
|
||||||
},
|
start: quoteResult.offset,
|
||||||
quoteResult.confidence,
|
end: quoteResult.offset + byType.TextQuoteSelector.exact.length,
|
||||||
used,
|
},
|
||||||
warnings,
|
...pageFor(
|
||||||
quoteResult.status,
|
{
|
||||||
|
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 globalStart = range.globalStart + pageText.start;
|
||||||
const globalEnd = range.globalStart + pageText.end;
|
const globalEnd = range.globalStart + pageText.end;
|
||||||
used.push("PdfPageTextSelector");
|
used.push("PdfPageTextSelector");
|
||||||
return resolved(
|
return finish(
|
||||||
{
|
resolved(
|
||||||
representationId,
|
{
|
||||||
page: pageText.page,
|
representationId,
|
||||||
textPosition: { start: globalStart, end: globalEnd },
|
page: pageText.page,
|
||||||
},
|
textPosition: { start: globalStart, end: globalEnd },
|
||||||
0.8,
|
},
|
||||||
used,
|
0.8,
|
||||||
warnings,
|
used,
|
||||||
|
warnings,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -123,15 +185,113 @@ export function resolveSelectors(
|
||||||
if (byType.PdfRectSelector) {
|
if (byType.PdfRectSelector) {
|
||||||
const rect = byType.PdfRectSelector;
|
const rect = byType.PdfRectSelector;
|
||||||
used.push("PdfRectSelector");
|
used.push("PdfRectSelector");
|
||||||
return resolved(
|
return finish(
|
||||||
{ representationId, page: rect.page, rects: rect.rects },
|
resolved(
|
||||||
0.7,
|
{ representationId, page: rect.page, rects: rect.rects },
|
||||||
used,
|
0.7,
|
||||||
warnings,
|
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 {
|
interface QuoteResolutionResult {
|
||||||
|
|
@ -146,7 +306,6 @@ function resolveByQuote(canonicalText: string, quote: TextQuoteSelector): QuoteR
|
||||||
if (positions.length === 1) {
|
if (positions.length === 1) {
|
||||||
return { offset: positions[0]!, confidence: 0.95, status: "resolved" };
|
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));
|
const filtered = positions.filter((p) => prefixSuffixMatches(canonicalText, p, quote));
|
||||||
if (filtered.length === 1) {
|
if (filtered.length === 1) {
|
||||||
return { offset: filtered[0]!, confidence: 0.9, status: "resolved" };
|
return { offset: filtered[0]!, confidence: 0.9, status: "resolved" };
|
||||||
|
|
@ -154,7 +313,6 @@ function resolveByQuote(canonicalText: string, quote: TextQuoteSelector): QuoteR
|
||||||
if (filtered.length > 1) {
|
if (filtered.length > 1) {
|
||||||
return { offset: filtered[0]!, confidence: 0.5, status: "ambiguous" };
|
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" };
|
return { offset: positions[0]!, confidence: 0.5, status: "ambiguous" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,6 +341,8 @@ interface SelectorIndex {
|
||||||
TextPositionSelector?: TextPositionSelector;
|
TextPositionSelector?: TextPositionSelector;
|
||||||
PdfRectSelector?: PdfRectSelector;
|
PdfRectSelector?: PdfRectSelector;
|
||||||
PdfPageTextSelector?: PdfPageTextSelector;
|
PdfPageTextSelector?: PdfPageTextSelector;
|
||||||
|
DomRangeSelector?: DomRangeSelector;
|
||||||
|
StructuralSelector?: StructuralSelector;
|
||||||
}
|
}
|
||||||
|
|
||||||
function indexByType(selectors: readonly Selector[]): SelectorIndex {
|
function indexByType(selectors: readonly Selector[]): SelectorIndex {
|
||||||
|
|
@ -201,6 +361,12 @@ function indexByType(selectors: readonly Selector[]): SelectorIndex {
|
||||||
case "PdfPageTextSelector":
|
case "PdfPageTextSelector":
|
||||||
idx.PdfPageTextSelector = s;
|
idx.PdfPageTextSelector = s;
|
||||||
break;
|
break;
|
||||||
|
case "DomRangeSelector":
|
||||||
|
idx.DomRangeSelector = s;
|
||||||
|
break;
|
||||||
|
case "StructuralSelector":
|
||||||
|
idx.StructuralSelector = s;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return idx;
|
return idx;
|
||||||
|
|
@ -238,23 +404,46 @@ function resolved(
|
||||||
confidence: number,
|
confidence: number,
|
||||||
used: readonly SelectorType[],
|
used: readonly SelectorType[],
|
||||||
warnings: readonly string[],
|
warnings: readonly string[],
|
||||||
status: "resolved" | "ambiguous" = "resolved",
|
status: "resolved" | "ambiguous" | "stale" = "resolved",
|
||||||
): AnchorResolution {
|
): AnchorResolution {
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
confidence,
|
confidence,
|
||||||
candidates: [target],
|
candidates: [target],
|
||||||
usedSelectorTypes: used,
|
usedSelectorTypes: used,
|
||||||
|
orphaned: false,
|
||||||
...(warnings.length > 0 ? { warnings } : {}),
|
...(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 {
|
function unresolved(warnings: readonly string[]): AnchorResolution {
|
||||||
return {
|
return {
|
||||||
status: "unresolved",
|
status: "unresolved",
|
||||||
confidence: 0,
|
confidence: 0,
|
||||||
candidates: [],
|
candidates: [],
|
||||||
usedSelectorTypes: [],
|
usedSelectorTypes: [],
|
||||||
|
orphaned: true,
|
||||||
...(warnings.length > 0 ? { warnings } : {}),
|
...(warnings.length > 0 ? { warnings } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function finish(resolution: AnchorResolution): AnchorResolution {
|
||||||
|
if (resolution.orphaned === undefined) {
|
||||||
|
return {
|
||||||
|
...resolution,
|
||||||
|
orphaned: isOrphanedResolution(resolution),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return resolution;
|
||||||
|
}
|
||||||
24
src/types.ts
24
src/types.ts
|
|
@ -12,7 +12,11 @@
|
||||||
import type { Document, DocumentRepresentation } from "@citation-evidence/engine/shared";
|
import type { Document, DocumentRepresentation } from "@citation-evidence/engine/shared";
|
||||||
import type { Selector } from "@citation-evidence/engine/shared";
|
import type { Selector } from "@citation-evidence/engine/shared";
|
||||||
import type { AnnotationResolutionStatus } 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
|
* The raw selection captured from a viewer adapter — an opaque payload that
|
||||||
|
|
@ -36,8 +40,17 @@ export interface PdfSelectionCapture {
|
||||||
readonly boundingRect?: NormalizedRect;
|
readonly boundingRect?: NormalizedRect;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reserved for the HTML/Markdown adapter. Not implementable in MVP. */
|
/** Selection captured from a rendered HTML/Markdown viewer. */
|
||||||
export type DomSelectionCapture = never;
|
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
|
* 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. */
|
/** Names of the selector kinds that produced a usable candidate. */
|
||||||
readonly usedSelectorTypes: readonly string[];
|
readonly usedSelectorTypes: readonly string[];
|
||||||
readonly warnings?: 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 {
|
export interface HighlightRenderOptions {
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ type: workplan
|
||||||
title: "Anchor resolution hardening: stale/orphan semantics, fuzzy re-anchoring, production PDF adapter"
|
title: "Anchor resolution hardening: stale/orphan semantics, fuzzy re-anchoring, production PDF adapter"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: evidence-anchor
|
repo: evidence-anchor
|
||||||
status: proposed
|
status: finished
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: citation_evidence_mvp
|
topic_slug: citation_evidence_mvp
|
||||||
created: "2026-07-08"
|
created: "2026-07-08"
|
||||||
updated: "2026-07-08"
|
updated: "2026-07-09"
|
||||||
spec_refs:
|
spec_refs:
|
||||||
- INTENT.md
|
- INTENT.md
|
||||||
- SCOPE.md
|
- SCOPE.md
|
||||||
|
|
@ -48,7 +48,7 @@ Current extracted behavior (`src/selectors/resolve.ts`):
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: EANCH-WP-0002-T01
|
id: EANCH-WP-0002-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "940b03ba-4b2f-4d1a-b7bc-72813817549d"
|
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
|
```task
|
||||||
id: EANCH-WP-0002-T02
|
id: EANCH-WP-0002-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
depends_on: [T01]
|
depends_on: [T01]
|
||||||
state_hub_task_id: "f75b94cd-bfb8-47f9-bd89-0d605f925290"
|
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
|
```task
|
||||||
id: EANCH-WP-0002-T03
|
id: EANCH-WP-0002-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
depends_on: [T01]
|
depends_on: [T01]
|
||||||
state_hub_task_id: "8548fa44-5bb3-40cf-b8f8-e745deae7dda"
|
state_hub_task_id: "8548fa44-5bb3-40cf-b8f8-e745deae7dda"
|
||||||
|
|
@ -95,7 +95,7 @@ floor.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: EANCH-WP-0002-T04
|
id: EANCH-WP-0002-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
depends_on: [T03]
|
depends_on: [T03]
|
||||||
state_hub_task_id: "6a7163f5-39ca-48fa-8941-8459c563198f"
|
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
|
```task
|
||||||
id: EANCH-WP-0002-T05
|
id: EANCH-WP-0002-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
depends_on: [T02, T04]
|
depends_on: [T02, T04]
|
||||||
state_hub_task_id: "1ea0885a-c541-438f-a4fd-b8be0773b373"
|
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
|
`pnpm test`/`typecheck`/`lint` green here; umbrella typecheck/test/build green
|
||||||
after any contract-visible change; `fix-consistency` clean; progress note.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ type: workplan
|
||||||
title: "Non-PDF selectors: HTML/Markdown DOM range + structural anchoring"
|
title: "Non-PDF selectors: HTML/Markdown DOM range + structural anchoring"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: evidence-anchor
|
repo: evidence-anchor
|
||||||
status: proposed
|
status: finished
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: citation_evidence_mvp
|
topic_slug: citation_evidence_mvp
|
||||||
created: "2026-07-08"
|
created: "2026-07-08"
|
||||||
updated: "2026-07-08"
|
updated: "2026-07-09"
|
||||||
spec_refs:
|
spec_refs:
|
||||||
- INTENT.md
|
- INTENT.md
|
||||||
- SCOPE.md
|
- SCOPE.md
|
||||||
|
|
@ -47,7 +47,7 @@ enum — coordinate contract changes in the umbrella wiki first.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: EANCH-WP-0003-T01
|
id: EANCH-WP-0003-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "de7df94f-2ddf-4920-949e-6e693131a9de"
|
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
|
```task
|
||||||
id: EANCH-WP-0003-T02
|
id: EANCH-WP-0003-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
depends_on: [T01]
|
depends_on: [T01]
|
||||||
state_hub_task_id: "aa1bc68d-c15a-41e0-813d-c4ccec4914c9"
|
state_hub_task_id: "aa1bc68d-c15a-41e0-813d-c4ccec4914c9"
|
||||||
|
|
@ -75,7 +75,7 @@ DOM selection. Add unit tests mirroring the PDF create tests.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: EANCH-WP-0003-T03
|
id: EANCH-WP-0003-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
depends_on: [T02]
|
depends_on: [T02]
|
||||||
state_hub_task_id: "2bdedc91-1ed2-41ed-b4a0-a9a0d7a6b4b0"
|
state_hub_task_id: "2bdedc91-1ed2-41ed-b4a0-a9a0d7a6b4b0"
|
||||||
|
|
@ -90,7 +90,7 @@ re-render stability (structural fallback when offsets shift).
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: EANCH-WP-0003-T04
|
id: EANCH-WP-0003-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
depends_on: [T03]
|
depends_on: [T03]
|
||||||
state_hub_task_id: "286267fe-d348-4494-bbe2-dd7818bcadbd"
|
state_hub_task_id: "286267fe-d348-4494-bbe2-dd7818bcadbd"
|
||||||
|
|
@ -105,7 +105,7 @@ to that boundary.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: EANCH-WP-0003-T05
|
id: EANCH-WP-0003-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
depends_on: [T04]
|
depends_on: [T04]
|
||||||
state_hub_task_id: "9ff39c83-eca8-46a1-9b28-8a728ddf220d"
|
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
|
Package green (`pnpm test`/`typecheck`/`lint`); engine contract change verified
|
||||||
in `citation-engine`; any umbrella consumer green; `fix-consistency` clean;
|
in `citation-engine`; any umbrella consumer green; `fix-consistency` clean;
|
||||||
progress note.
|
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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue