@openparser/lineage 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/LICENSE +201 -0
  3. package/README.md +211 -0
  4. package/dist/activity.d.ts +74 -0
  5. package/dist/activity.d.ts.map +1 -0
  6. package/dist/approval.d.ts +24 -0
  7. package/dist/approval.d.ts.map +1 -0
  8. package/dist/builder.d.ts +48 -0
  9. package/dist/builder.d.ts.map +1 -0
  10. package/dist/common.d.ts +34 -0
  11. package/dist/common.d.ts.map +1 -0
  12. package/dist/confidence.d.ts +61 -0
  13. package/dist/confidence.d.ts.map +1 -0
  14. package/dist/derivation.d.ts +163 -0
  15. package/dist/derivation.d.ts.map +1 -0
  16. package/dist/document.d.ts +412 -0
  17. package/dist/document.d.ts.map +1 -0
  18. package/dist/entity.d.ts +126 -0
  19. package/dist/entity.d.ts.map +1 -0
  20. package/dist/fields.d.ts +76 -0
  21. package/dist/fields.d.ts.map +1 -0
  22. package/dist/index.d.ts +21 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +870 -0
  25. package/dist/labels.d.ts +11 -0
  26. package/dist/labels.d.ts.map +1 -0
  27. package/dist/profile/grounding.d.ts +34 -0
  28. package/dist/profile/grounding.d.ts.map +1 -0
  29. package/dist/profile/index.d.ts +14 -0
  30. package/dist/profile/index.d.ts.map +1 -0
  31. package/dist/profile/index.js +1298 -0
  32. package/dist/profile/labels.d.ts +10 -0
  33. package/dist/profile/labels.d.ts.map +1 -0
  34. package/dist/profile/recognition.d.ts +70 -0
  35. package/dist/profile/recognition.d.ts.map +1 -0
  36. package/dist/profile/transform-validation.d.ts +250 -0
  37. package/dist/profile/transform-validation.d.ts.map +1 -0
  38. package/dist/prov.d.ts +40 -0
  39. package/dist/prov.d.ts.map +1 -0
  40. package/dist/relation.d.ts +27 -0
  41. package/dist/relation.d.ts.map +1 -0
  42. package/dist/traverse.d.ts +12 -0
  43. package/dist/traverse.d.ts.map +1 -0
  44. package/dist/validate.d.ts +14 -0
  45. package/dist/validate.d.ts.map +1 -0
  46. package/lineage.schema.json +625 -0
  47. package/package.json +71 -0
  48. package/src/activity.ts +65 -0
  49. package/src/approval.ts +28 -0
  50. package/src/builder.ts +129 -0
  51. package/src/common.ts +58 -0
  52. package/src/confidence.ts +72 -0
  53. package/src/derivation.ts +81 -0
  54. package/src/document.ts +82 -0
  55. package/src/entity.ts +87 -0
  56. package/src/fields.ts +163 -0
  57. package/src/index.ts +123 -0
  58. package/src/labels.ts +13 -0
  59. package/src/profile/grounding.ts +74 -0
  60. package/src/profile/index.ts +67 -0
  61. package/src/profile/labels.ts +28 -0
  62. package/src/profile/recognition.ts +149 -0
  63. package/src/profile/transform-validation.ts +1379 -0
  64. package/src/prov.ts +205 -0
  65. package/src/relation.ts +32 -0
  66. package/src/traverse.ts +91 -0
  67. package/src/validate.ts +221 -0
package/src/fields.ts ADDED
@@ -0,0 +1,163 @@
1
+ import type { JsonValue } from './common';
2
+ import type { ConfidenceAssertion, ConfidenceKind } from './confidence';
3
+ import type { LineageDocument } from './document';
4
+ import type { Entity } from './entity';
5
+ import { entityAncestors } from './traverse';
6
+
7
+ /** How a field's confidence came about, or that none was asserted at all. */
8
+ export type ConfidenceOrigin = ConfidenceKind | 'not_reported';
9
+
10
+ export type LineageField = {
11
+ id: string;
12
+ pointer: string;
13
+ /**
14
+ * Display-only dotted rendering of `pointer`. Lossy: a property whose name
15
+ * contains a dot is indistinguishable from nesting. Use `pointer` as the key.
16
+ */
17
+ dottedPath: string;
18
+ value: JsonValue | undefined;
19
+ confidence: ConfidenceAssertion | undefined;
20
+ confidenceOrigin: ConfidenceOrigin;
21
+ entity: Entity;
22
+ };
23
+
24
+ export type LineageFieldTrace = {
25
+ field: LineageField;
26
+ evidence: Array<{ id: string; entity: Entity }>;
27
+ activities: string[];
28
+ };
29
+
30
+ /** RFC 6901 pointer for an output entity. `path` is the only field locator. */
31
+ function fieldPointer(entity: Entity): string {
32
+ return entity.path ?? '';
33
+ }
34
+
35
+ /** Most relevant confidence assertion: extraction-scoped, else the first. */
36
+ function fieldConfidence(entity: Entity): ConfidenceAssertion | undefined {
37
+ const assertions = entity.confidence;
38
+ if (!assertions || assertions.length === 0) return undefined;
39
+ return assertions.find((assertion) => assertion.scope === 'extraction') ?? assertions[0];
40
+ }
41
+
42
+ /**
43
+ * RFC 6901 pointer to dotted display path, unescaping `~1` and `~0`.
44
+ *
45
+ * Display only. A property whose name contains a dot is indistinguishable from
46
+ * nesting, so never use the result as a key.
47
+ */
48
+ export function dottedPathFromPointer(pointer: string): string {
49
+ if (pointer === '' || pointer === '/') return '';
50
+ return pointer
51
+ .replace(/^\//, '')
52
+ .split('/')
53
+ .map((segment) => segment.replace(/~1/g, '/').replace(/~0/g, '~'))
54
+ .join('.');
55
+ }
56
+
57
+ /**
58
+ * Dotted display path back to an RFC 6901 pointer, escaping `~` and `/`.
59
+ *
60
+ * Only sound for paths `dottedPathFromPointer` could have produced: a property
61
+ * name containing a dot round-trips as nesting. Prefer carrying the pointer.
62
+ */
63
+ export function pointerFromDottedPath(dottedPath: string): string {
64
+ if (dottedPath === '') return '';
65
+ return `/${dottedPath
66
+ .split('.')
67
+ .map((segment) => segment.replaceAll('~', '~0').replaceAll('/', '~1'))
68
+ .join('/')}`;
69
+ }
70
+
71
+ /**
72
+ * The entity holding a field's current value at `pointer`.
73
+ *
74
+ * Precedence is explicit and does not use `Object.entries` insertion order:
75
+ * 1. An entity at that path listed in `doc.outputs` — after review, `outputs`
76
+ * is the current field set (plus a root snapshot / completion decision,
77
+ * which do not share field paths). If several outputs match, the earliest
78
+ * in `doc.outputs` wins.
79
+ * 2. Otherwise a `kind: 'decision'` entity at that path (the producer-settled
80
+ * value on documents that never went through review). If several decisions
81
+ * match, the lexicographically smallest id wins.
82
+ * 3. Otherwise the lexicographically smallest id among remaining matches.
83
+ *
84
+ * `path` is the only field locator, so this is the single supported way to go
85
+ * from a pointer to an entity id — ids themselves are not a stable contract.
86
+ */
87
+ export function fieldEntityByPointer(
88
+ doc: LineageDocument,
89
+ pointer: string
90
+ ): { id: string; entity: Entity } | undefined {
91
+ const matching = Object.entries(doc.entities).filter(([, entity]) => entity.path === pointer);
92
+ if (matching.length === 0) return undefined;
93
+
94
+ const matchingIds = new Set(matching.map(([id]) => id));
95
+ const outputId = doc.outputs.find((id) => matchingIds.has(id));
96
+ if (outputId) {
97
+ const entity = doc.entities[outputId];
98
+ if (entity) return { id: outputId, entity };
99
+ }
100
+
101
+ const ranked = matching.toSorted(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
102
+ const decision = ranked.find(([, entity]) => entity.kind === 'decision');
103
+ const chosen = decision ?? ranked[0];
104
+ return chosen ? { id: chosen[0], entity: chosen[1] } : undefined;
105
+ }
106
+
107
+ function toField(id: string, entity: Entity): LineageField {
108
+ const pointer = fieldPointer(entity);
109
+ const confidence = fieldConfidence(entity);
110
+ return {
111
+ id,
112
+ pointer,
113
+ dottedPath: dottedPathFromPointer(pointer),
114
+ value: entity.value,
115
+ confidence,
116
+ confidenceOrigin: confidence ? confidence.kind : 'not_reported',
117
+ entity,
118
+ };
119
+ }
120
+
121
+ /** Output field entities as fields, in `doc.outputs` order.
122
+ *
123
+ * Whole-document snapshots (`path === ''`) and other pathless outputs are
124
+ * omitted — they are the whole result (or a run-level decision), not a field.
125
+ */
126
+ export function fields(doc: LineageDocument): LineageField[] {
127
+ const result: LineageField[] = [];
128
+ for (const id of doc.outputs) {
129
+ const entity = doc.entities[id];
130
+ if (!entity) continue;
131
+ if (fieldPointer(entity) === '') continue;
132
+ result.push(toField(id, entity));
133
+ }
134
+ return result;
135
+ }
136
+
137
+ /**
138
+ * Trace a field by JSON Pointer, including upstream evidence and activities.
139
+ *
140
+ * Whether that evidence is *sufficient* is a question for whoever produced the
141
+ * document, since it turns on their own attributes; see the OpenParser profile
142
+ * for one answer.
143
+ */
144
+ export function fieldTrace(doc: LineageDocument, pointer: string): LineageFieldTrace | undefined {
145
+ const field = fields(doc).find((candidate) => candidate.pointer === pointer);
146
+ if (!field) return undefined;
147
+
148
+ const ancestorIds = entityAncestors(doc, field.id);
149
+ const evidence = ancestorIds
150
+ .filter((id) => {
151
+ const entity = doc.entities[id];
152
+ return entity?.kind === 'collection' || entity?.kind === 'evidence';
153
+ })
154
+ .sort()
155
+ .map((id) => ({ id, entity: doc.entities[id]! }));
156
+
157
+ const relevant = new Set([field.id, ...ancestorIds]);
158
+ const activities = [
159
+ ...new Set(doc.derivations.filter((d) => relevant.has(d.output)).map((d) => d.activity)),
160
+ ].sort();
161
+
162
+ return { field, evidence, activities };
163
+ }
package/src/index.ts ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Public `@openparser/lineage` surface for `lineage@1` complete data-derivation DAGs.
3
+ *
4
+ * Provider- and domain-agnostic provenance documents grounded in W3C PROV-DM
5
+ * semantics with ergonomic JSON. Root export only — no subpath exports.
6
+ */
7
+
8
+ export {
9
+ ACTIVITY_STATUSES,
10
+ ActivitySchema,
11
+ ActivityStatusSchema,
12
+ AgentSchema,
13
+ AssociationSchema,
14
+ ImplementationSchema,
15
+ type Activity,
16
+ type ActivityStatus,
17
+ type Agent,
18
+ type Association,
19
+ type Implementation,
20
+ } from './activity';
21
+
22
+ export { ApprovalAssertionSchema, type ApprovalAssertion } from './approval';
23
+
24
+ export { LineageBuilder } from './builder';
25
+
26
+ export {
27
+ AttributesSchema,
28
+ JsonPointerSchema,
29
+ JsonValueSchema,
30
+ LINEAGE_FORMAT,
31
+ LineageFormatSchema,
32
+ LineageIdSchema,
33
+ TimestampSchema,
34
+ jsonValuesEqual,
35
+ type Attributes,
36
+ type JsonPointer,
37
+ type JsonValue,
38
+ type LineageFormat,
39
+ type LineageId,
40
+ type Timestamp,
41
+ } from './common';
42
+
43
+ export {
44
+ CONFIDENCE_KINDS,
45
+ ConfidenceAssertionSchema,
46
+ ConfidenceKindSchema,
47
+ ConfidenceScaleSchema,
48
+ ConfidenceSourceSchema,
49
+ type ConfidenceAssertion,
50
+ type ConfidenceKind,
51
+ type ConfidenceScale,
52
+ type ConfidenceSource,
53
+ } from './confidence';
54
+
55
+ export {
56
+ DERIVATION_EFFECTS,
57
+ DerivationEffectSchema,
58
+ DerivationInputSchema,
59
+ DerivationSchema,
60
+ MAX_DERIVATION_INPUTS,
61
+ TransformationSchema,
62
+ derivationId,
63
+ type Derivation,
64
+ type DerivationEffect,
65
+ type DerivationInput,
66
+ type Transformation,
67
+ } from './derivation';
68
+
69
+ export {
70
+ LineageDocumentSchema,
71
+ LineageDocumentShapeSchema,
72
+ parseLineageDocument,
73
+ safeParseLineageDocument,
74
+ type LineageDocument,
75
+ } from './document';
76
+
77
+ export {
78
+ AttributionSchema,
79
+ DigestSchema,
80
+ ENTITY_KINDS,
81
+ EntityKindSchema,
82
+ EntitySchema,
83
+ LocatorSchema,
84
+ SelectorSchema,
85
+ type Attribution,
86
+ type Digest,
87
+ type Entity,
88
+ type EntityKind,
89
+ type Locator,
90
+ type Selector,
91
+ } from './entity';
92
+
93
+ export {
94
+ LINEAGE_PROV_NAMESPACE,
95
+ lineageIdToProvQualifiedName,
96
+ toProvJson,
97
+ type ProvJsonDocument,
98
+ } from './prov';
99
+
100
+ export {
101
+ RELATION_TYPES,
102
+ RelationSchema,
103
+ RelationTypeSchema,
104
+ type Relation,
105
+ type RelationType,
106
+ } from './relation';
107
+
108
+ export {
109
+ dottedPathFromPointer,
110
+ fieldEntityByPointer,
111
+ fieldTrace,
112
+ fields,
113
+ pointerFromDottedPath,
114
+ type ConfidenceOrigin,
115
+ type LineageField,
116
+ type LineageFieldTrace,
117
+ } from './fields';
118
+
119
+ export { activityLabel } from './labels';
120
+
121
+ export { entityAncestors, entityDescendants, topologicalEntityOrder } from './traverse';
122
+
123
+ export { LineageValidationError, validateLineageGraph } from './validate';
package/src/labels.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { Activity } from './activity';
2
+
3
+ /**
4
+ * Display label for an activity: an explicit `name` when a producer set one,
5
+ * otherwise the raw `type`.
6
+ *
7
+ * `type` is an open string. Core `lineage@1` does not map vendor activity types
8
+ * to presentation copy — OpenParser-friendly names live in
9
+ * `@openparser/lineage/openparser`.
10
+ */
11
+ export function activityLabel(activity: Activity): string {
12
+ return activity.name ?? activity.type;
13
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Whether a field's evidence is sufficient, as the OpenParser profile defines it.
3
+ *
4
+ * `lineage@1` records what a value was derived from; it takes no position on when
5
+ * that is enough, because "enough" depends on what the producer was able to
6
+ * resolve. OpenParser answers it with one attribute on the producer derivation —
7
+ * the source ids the model named that could not be resolved to a region — so the
8
+ * rule reads a `openparser:` key and belongs here rather than in the protocol.
9
+ */
10
+
11
+ import type { Derivation } from '../derivation';
12
+ import type { LineageDocument } from '../document';
13
+ import { fieldTrace } from '../fields';
14
+ import { entityAncestors } from '../traverse';
15
+
16
+ const DROPPED_SOURCES_ATTR = 'openparser:droppedSourceIds';
17
+
18
+ /** Whether a field is backed by source evidence, partially backed, or unbacked. */
19
+ export type GroundingStatus = 'grounded' | 'partial' | 'ungrounded';
20
+
21
+ export type FieldGrounding = {
22
+ status: GroundingStatus;
23
+ /** Source ids the producer cited but could not resolve to a region. */
24
+ droppedSourceIds: string[];
25
+ };
26
+
27
+ /** Source ids the producer named but could not resolve to a region. */
28
+ export function droppedSourceIds(producer: Derivation | undefined): string[] {
29
+ const dropped = producer?.attributes?.[DROPPED_SOURCES_ATTR];
30
+ return Array.isArray(dropped) ? dropped.filter((id): id is string => typeof id === 'string') : [];
31
+ }
32
+
33
+ /**
34
+ * Grounding is read off the graph rather than stored: the evidence a value
35
+ * actually reaches, less the sources its producer could not resolve.
36
+ *
37
+ * Callers that already walked to the evidence pass the count, since how far a
38
+ * given consumer follows the graph is its own business.
39
+ */
40
+ export function groundingStatus(options: {
41
+ evidenceCount: number;
42
+ droppedSourceIds: string[];
43
+ }): GroundingStatus {
44
+ if (options.evidenceCount === 0) return 'ungrounded';
45
+ return options.droppedSourceIds.length > 0 ? 'partial' : 'grounded';
46
+ }
47
+
48
+ function collectDroppedSourceIds(doc: LineageDocument, entityIds: Iterable<string>): string[] {
49
+ const relevant = new Set(entityIds);
50
+ const seen = new Set<string>();
51
+ const dropped: string[] = [];
52
+ for (const derivation of doc.derivations) {
53
+ if (!relevant.has(derivation.output)) continue;
54
+ for (const id of droppedSourceIds(derivation)) {
55
+ if (seen.has(id)) continue;
56
+ seen.add(id);
57
+ dropped.push(id);
58
+ }
59
+ }
60
+ return dropped;
61
+ }
62
+
63
+ /** Grounding for the field at a JSON Pointer, walking the graph to its evidence. */
64
+ export function fieldGrounding(doc: LineageDocument, pointer: string): FieldGrounding | undefined {
65
+ const trace = fieldTrace(doc, pointer);
66
+ if (!trace) return undefined;
67
+
68
+ const ancestorIds = entityAncestors(doc, trace.field.id);
69
+ const dropped = collectDroppedSourceIds(doc, [trace.field.id, ...ancestorIds]);
70
+ return {
71
+ status: groundingStatus({ evidenceCount: trace.evidence.length, droppedSourceIds: dropped }),
72
+ droppedSourceIds: dropped,
73
+ };
74
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The OpenParser profile of `lineage@1`, published as `@openparser/lineage/openparser`.
3
+ *
4
+ * `lineage@1` is a vendor-neutral record of where values came from. Anything that
5
+ * depends on how OpenParser in particular scores an OCR word, or on an
6
+ * `openparser:` attribute, lives here — so the protocol surface stays readable as
7
+ * a protocol, and a producer that is not OpenParser is never asked to adopt our
8
+ * conventions to use it.
9
+ */
10
+
11
+ export { activityLabel } from './labels';
12
+
13
+ export {
14
+ droppedSourceIds,
15
+ fieldGrounding,
16
+ groundingStatus,
17
+ type FieldGrounding,
18
+ type GroundingStatus,
19
+ } from './grounding';
20
+
21
+ export {
22
+ CITED_RECOGNITION_METHOD,
23
+ CONTEXT_BLEND_WEIGHT,
24
+ REGION_RECOGNITION_METHOD,
25
+ aggregateCitedWordConfidences,
26
+ annotateWordsWithCitation,
27
+ citedSpanInText,
28
+ downwardBlendCitedMinimum,
29
+ type CitedWordAggregate,
30
+ type ScoredWord,
31
+ } from './recognition';
32
+
33
+ export {
34
+ DATE_SOURCE_CATALOG_ID,
35
+ DATE_SOURCE_CATALOG_V1,
36
+ DATE_SOURCE_CATALOG_VERSION,
37
+ FORMAT_ONLY_DATE_TARGETS,
38
+ MAX_TRANSFORM_INTENT_PARAMETER_BYTES,
39
+ MAX_TRANSFORM_PROOF_BYTES,
40
+ TRANSFORM_DOCS_ANCHORS,
41
+ TRANSFORM_OPERATIONS,
42
+ TRANSFORM_PROOF_ATTRIBUTE,
43
+ TRANSFORM_PROOF_OPERAND_KEYS,
44
+ TRANSFORM_VALIDATORS,
45
+ TRANSFORM_VERIFICATION_DOCS,
46
+ TransformClaimSchema,
47
+ TransformIntentSchema,
48
+ TransformProofClassSchema,
49
+ TransformProofSchema,
50
+ dateTimeFormatIssue,
51
+ formatOnlyDateTimeParameters,
52
+ isTrustedTransformDocsHref,
53
+ parseTransformProof,
54
+ transformValidatorDescriptor,
55
+ trustedDateSourceFormats,
56
+ trustedIntentParametersFitProof,
57
+ trustedTransformDocsHref,
58
+ verifyTransform,
59
+ type DateTimeKind,
60
+ type TransformClaim,
61
+ type TransformIntent,
62
+ type TransformOperation,
63
+ type TransformProof,
64
+ type TransformProofClass,
65
+ type TransformValidatorDescriptor,
66
+ type TransformVerificationResult,
67
+ } from './transform-validation';
@@ -0,0 +1,28 @@
1
+ import type { Activity } from '../activity';
2
+
3
+ /**
4
+ * Human labels for the activity types this profile emits.
5
+ *
6
+ * Documents do not carry generated display strings, so this is where a label is
7
+ * recovered from `activity.type`. Keeping it here rather than in each dashboard
8
+ * means one mapping to translate or correct, and it stays available to anyone
9
+ * reading a document through this library.
10
+ */
11
+ const ACTIVITY_LABELS: Record<string, string> = {
12
+ ocr: 'Parse',
13
+ extract: 'Schema-constrained extraction',
14
+ transform: 'Transform',
15
+ grounding_resolution: 'Grounding resolution and validation',
16
+ 'review.confirm': 'Human review',
17
+ };
18
+
19
+ /**
20
+ * Display label for an OpenParser activity: an explicit `name` when a producer
21
+ * set one, otherwise a label for known types, otherwise the raw type.
22
+ *
23
+ * `type` is an open string, so unknown types render as themselves rather than
24
+ * being hidden behind a placeholder.
25
+ */
26
+ export function activityLabel(activity: Activity): string {
27
+ return activity.name ?? ACTIVITY_LABELS[activity.type] ?? activity.type;
28
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * How a value's recognition score is derived from the OCR words behind it.
3
+ *
4
+ * This is a profile rule, not part of `lineage@1`: the protocol says a score has
5
+ * a scale, a scope, and a named method, and stays silent on what any particular
6
+ * method computes. It ships beside the protocol because a producer and a consumer
7
+ * that disagree here disagree about what a number means. A region cited by two
8
+ * fields is read once but quoted differently by each, so scoring the region and
9
+ * scoring a value are different questions — and answering the second with the
10
+ * first is how a crisply scanned number ends up wearing its neighbor's bad
11
+ * handwriting.
12
+ */
13
+
14
+ /** An OCR token with its recognition score, and whether a value was read from it. */
15
+ export type ScoredWord = {
16
+ id?: string;
17
+ text: string;
18
+ confidence: number;
19
+ cited?: boolean;
20
+ };
21
+
22
+ export const CITED_RECOGNITION_METHOD = 'cited_minimum_context_downblend';
23
+
24
+ /** Scoring a whole region, because no word within it could be narrowed to. */
25
+ export const REGION_RECOGNITION_METHOD = 'region_word_minimum';
26
+
27
+ /** How far the surrounding text may pull a cited score down. */
28
+ export const CONTEXT_BLEND_WEIGHT = 0.15;
29
+
30
+ /**
31
+ * Blend a cited-word minimum with the surrounding context: weaker context may
32
+ * only pull the score down, and never below what the blend allows. A noisy
33
+ * neighborhood is evidence that a value might be misread, not a verdict that it
34
+ * was.
35
+ */
36
+ export function downwardBlendCitedMinimum(
37
+ citedMin: number,
38
+ contextMean: number | undefined
39
+ ): number {
40
+ if (contextMean === undefined) return citedMin;
41
+ const blended = citedMin * (1 - CONTEXT_BLEND_WEIGHT) + contextMean * CONTEXT_BLEND_WEIGHT;
42
+ return Math.min(citedMin, blended);
43
+ }
44
+
45
+ export type CitedWordAggregate = {
46
+ score: number;
47
+ minimum: number;
48
+ maximum: number;
49
+ sampleCount: number;
50
+ method: typeof CITED_RECOGNITION_METHOD | typeof REGION_RECOGNITION_METHOD;
51
+ contextMean?: number;
52
+ contextSampleCount?: number;
53
+ };
54
+
55
+ /**
56
+ * Score one value from the words it was read from, discounted by the words
57
+ * around it. With nothing marked cited there is nothing to narrow to, so every
58
+ * word counts, the result is the region's own weakest token, and `method` says
59
+ * so — a score over the whole region must not claim to have narrowed to a value.
60
+ */
61
+ export function aggregateCitedWordConfidences(
62
+ words: readonly ScoredWord[]
63
+ ): CitedWordAggregate | undefined {
64
+ if (words.length === 0) return undefined;
65
+ const cited = words.filter((word) => word.cited !== false);
66
+ const context = words.filter((word) => word.cited === false);
67
+ const active = cited.length > 0 ? cited : words;
68
+ const activeScores = active.map((word) => word.confidence);
69
+ const contextMean =
70
+ cited.length > 0 && context.length > 0
71
+ ? context.reduce((sum, word) => sum + word.confidence, 0) / context.length
72
+ : undefined;
73
+ const citedMin = Math.min(...activeScores);
74
+
75
+ return {
76
+ score: downwardBlendCitedMinimum(citedMin, contextMean),
77
+ minimum: citedMin,
78
+ maximum: Math.max(...activeScores),
79
+ sampleCount: active.length,
80
+ method: cited.length > 0 ? CITED_RECOGNITION_METHOD : REGION_RECOGNITION_METHOD,
81
+ ...(contextMean !== undefined ? { contextMean, contextSampleCount: context.length } : {}),
82
+ };
83
+ }
84
+
85
+ /** First candidate found in the text wins; pass candidates in priority order. */
86
+ export function citedSpanInText(
87
+ regionText: string,
88
+ candidates: ReadonlyArray<string | undefined>
89
+ ): { start: number; end: number } {
90
+ if (!regionText) return { start: -1, end: -1 };
91
+ for (const candidate of candidates) {
92
+ const quote = candidate?.trim();
93
+ if (!quote) continue;
94
+ let start = regionText.indexOf(quote);
95
+ if (start < 0) start = regionText.toLowerCase().indexOf(quote.toLowerCase());
96
+ if (start >= 0) return { start, end: start + quote.length };
97
+ }
98
+ return { start: -1, end: -1 };
99
+ }
100
+
101
+ function wordOverlapsSpan(
102
+ regionText: string,
103
+ word: ScoredWord,
104
+ cursor: number,
105
+ span: { start: number; end: number }
106
+ ): { overlaps: boolean; nextCursor: number } {
107
+ if (!word.text) return { overlaps: false, nextCursor: cursor };
108
+ const at = regionText.indexOf(word.text, cursor);
109
+ if (at < 0) return { overlaps: false, nextCursor: cursor };
110
+ const wordEnd = at + word.text.length;
111
+ return {
112
+ overlaps: span.start >= 0 && at < span.end && wordEnd > span.start,
113
+ nextCursor: wordEnd,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Mark the words a value was read from within a region.
119
+ *
120
+ * `extractedText` is what the extraction produced, which is always text present
121
+ * in the document: for a value returned as-is it is the value itself, and for one
122
+ * the model went on to rewrite — `2024-05-30` from `MAY 30, 2024` — it is the text
123
+ * that was actually read. Searching for the value instead would find nothing in
124
+ * the second case. `lineageWordTexts`/`lineageWordIds` name words a lineage
125
+ * document already attributes to the value, used when no span matches.
126
+ */
127
+ export function annotateWordsWithCitation<T extends ScoredWord>(
128
+ regionText: string,
129
+ words: readonly T[],
130
+ options?: {
131
+ extractedText?: string;
132
+ lineageWordTexts?: ReadonlySet<string>;
133
+ lineageWordIds?: ReadonlySet<string>;
134
+ }
135
+ ): Array<T & { cited: boolean }> {
136
+ const span = citedSpanInText(regionText, [options?.extractedText]);
137
+ const hasQuoteSpan = span.start >= 0;
138
+ let cursor = 0;
139
+
140
+ return words.map((word) => {
141
+ const { overlaps, nextCursor } = wordOverlapsSpan(regionText, word, cursor, span);
142
+ cursor = nextCursor;
143
+ const inLineage = Boolean(
144
+ (word.id && options?.lineageWordIds?.has(word.id)) ||
145
+ options?.lineageWordTexts?.has(word.text)
146
+ );
147
+ return { ...word, cited: hasQuoteSpan ? overlaps : inLineage };
148
+ });
149
+ }