@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.
- package/CHANGELOG.md +89 -0
- package/LICENSE +201 -0
- package/README.md +211 -0
- package/dist/activity.d.ts +74 -0
- package/dist/activity.d.ts.map +1 -0
- package/dist/approval.d.ts +24 -0
- package/dist/approval.d.ts.map +1 -0
- package/dist/builder.d.ts +48 -0
- package/dist/builder.d.ts.map +1 -0
- package/dist/common.d.ts +34 -0
- package/dist/common.d.ts.map +1 -0
- package/dist/confidence.d.ts +61 -0
- package/dist/confidence.d.ts.map +1 -0
- package/dist/derivation.d.ts +163 -0
- package/dist/derivation.d.ts.map +1 -0
- package/dist/document.d.ts +412 -0
- package/dist/document.d.ts.map +1 -0
- package/dist/entity.d.ts +126 -0
- package/dist/entity.d.ts.map +1 -0
- package/dist/fields.d.ts +76 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +870 -0
- package/dist/labels.d.ts +11 -0
- package/dist/labels.d.ts.map +1 -0
- package/dist/profile/grounding.d.ts +34 -0
- package/dist/profile/grounding.d.ts.map +1 -0
- package/dist/profile/index.d.ts +14 -0
- package/dist/profile/index.d.ts.map +1 -0
- package/dist/profile/index.js +1298 -0
- package/dist/profile/labels.d.ts +10 -0
- package/dist/profile/labels.d.ts.map +1 -0
- package/dist/profile/recognition.d.ts +70 -0
- package/dist/profile/recognition.d.ts.map +1 -0
- package/dist/profile/transform-validation.d.ts +250 -0
- package/dist/profile/transform-validation.d.ts.map +1 -0
- package/dist/prov.d.ts +40 -0
- package/dist/prov.d.ts.map +1 -0
- package/dist/relation.d.ts +27 -0
- package/dist/relation.d.ts.map +1 -0
- package/dist/traverse.d.ts +12 -0
- package/dist/traverse.d.ts.map +1 -0
- package/dist/validate.d.ts +14 -0
- package/dist/validate.d.ts.map +1 -0
- package/lineage.schema.json +625 -0
- package/package.json +71 -0
- package/src/activity.ts +65 -0
- package/src/approval.ts +28 -0
- package/src/builder.ts +129 -0
- package/src/common.ts +58 -0
- package/src/confidence.ts +72 -0
- package/src/derivation.ts +81 -0
- package/src/document.ts +82 -0
- package/src/entity.ts +87 -0
- package/src/fields.ts +163 -0
- package/src/index.ts +123 -0
- package/src/labels.ts +13 -0
- package/src/profile/grounding.ts +74 -0
- package/src/profile/index.ts +67 -0
- package/src/profile/labels.ts +28 -0
- package/src/profile/recognition.ts +149 -0
- package/src/profile/transform-validation.ts +1379 -0
- package/src/prov.ts +205 -0
- package/src/relation.ts +32 -0
- package/src/traverse.ts +91 -0
- package/src/validate.ts +221 -0
package/src/prov.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { derivationId } from './derivation';
|
|
2
|
+
import type { LineageDocument } from './document';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Subset of W3C PROV-JSON used for interoperability export.
|
|
6
|
+
*
|
|
7
|
+
* Note: PROV-JSON itself is a W3C Member Submission
|
|
8
|
+
* (https://www.w3.org/Submission/2013/SUBM-prov-json-20130424/).
|
|
9
|
+
* The conceptual model is PROV-DM, a W3C Recommendation
|
|
10
|
+
* (https://www.w3.org/TR/prov-dm/).
|
|
11
|
+
*/
|
|
12
|
+
export type ProvJsonDocument = {
|
|
13
|
+
prefix?: Record<string, string>;
|
|
14
|
+
entity?: Record<string, Record<string, unknown>>;
|
|
15
|
+
activity?: Record<string, Record<string, unknown>>;
|
|
16
|
+
agent?: Record<string, Record<string, unknown>>;
|
|
17
|
+
wasDerivedFrom?: Record<string, Record<string, unknown>>;
|
|
18
|
+
wasGeneratedBy?: Record<string, Record<string, unknown>>;
|
|
19
|
+
used?: Record<string, Record<string, unknown>>;
|
|
20
|
+
wasAssociatedWith?: Record<string, Record<string, unknown>>;
|
|
21
|
+
wasAttributedTo?: Record<string, Record<string, unknown>>;
|
|
22
|
+
hadMember?: Record<string, Record<string, unknown>>;
|
|
23
|
+
specializationOf?: Record<string, Record<string, unknown>>;
|
|
24
|
+
alternateOf?: Record<string, Record<string, unknown>>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Where the `lineage@1` vocabulary is published, so PROV consumers can resolve
|
|
29
|
+
* the `lineage:` prefix. It names the format, not a producer — a document emitted
|
|
30
|
+
* by anyone binds the same prefix. Moving it breaks every consumer that has
|
|
31
|
+
* already resolved a term against it, so it is versioned with the format itself.
|
|
32
|
+
*/
|
|
33
|
+
export const LINEAGE_PROV_NAMESPACE = 'https://openparser.dev/ns/lineage#';
|
|
34
|
+
|
|
35
|
+
export function lineageIdToProvQualifiedName(id: string): string {
|
|
36
|
+
const encoded = [...new TextEncoder().encode(id)]
|
|
37
|
+
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
38
|
+
.join('');
|
|
39
|
+
return `lineage:id_${encoded}`;
|
|
40
|
+
}
|
|
41
|
+
const qname = lineageIdToProvQualifiedName;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Map a validated `lineage@1` document to W3C PROV-JSON concepts.
|
|
45
|
+
*
|
|
46
|
+
* Derivations expand to `wasDerivedFrom` (per input), `wasGeneratedBy`
|
|
47
|
+
* (output ← activity), and `used` (activity ← input). Associations,
|
|
48
|
+
* attributions, and membership follow PROV relations.
|
|
49
|
+
*/
|
|
50
|
+
export function toProvJson(doc: LineageDocument): ProvJsonDocument {
|
|
51
|
+
const out: ProvJsonDocument = {
|
|
52
|
+
prefix: {
|
|
53
|
+
prov: 'http://www.w3.org/ns/prov#',
|
|
54
|
+
lineage: LINEAGE_PROV_NAMESPACE,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const entities: Record<string, Record<string, unknown>> = {};
|
|
59
|
+
for (const [id, entity] of Object.entries(doc.entities)) {
|
|
60
|
+
const record: Record<string, unknown> = flattenAttrs(entity.attributes);
|
|
61
|
+
record['prov:type'] = entity.kind;
|
|
62
|
+
record['lineage:id'] = id;
|
|
63
|
+
if (entity.name) record['prov:label'] = entity.name;
|
|
64
|
+
if (entity.value !== undefined) record['lineage:value'] = entity.value;
|
|
65
|
+
// The empty pointer addresses the whole document, so absence is the test.
|
|
66
|
+
if (entity.path !== undefined) record['lineage:path'] = entity.path;
|
|
67
|
+
if (entity.locator?.uri) record['prov:location'] = entity.locator.uri;
|
|
68
|
+
if (entity.digest) {
|
|
69
|
+
record['lineage:digestAlgorithm'] = entity.digest.algorithm;
|
|
70
|
+
record['lineage:digestValue'] = entity.digest.value;
|
|
71
|
+
}
|
|
72
|
+
entities[qname(id)] = record;
|
|
73
|
+
}
|
|
74
|
+
if (Object.keys(entities).length > 0) out.entity = entities;
|
|
75
|
+
|
|
76
|
+
const activities: Record<string, Record<string, unknown>> = {};
|
|
77
|
+
for (const [id, activity] of Object.entries(doc.activities)) {
|
|
78
|
+
const record: Record<string, unknown> = flattenAttrs(activity.attributes);
|
|
79
|
+
record['prov:type'] = activity.type;
|
|
80
|
+
record['lineage:id'] = id;
|
|
81
|
+
if (activity.name) record['prov:label'] = activity.name;
|
|
82
|
+
if (activity.startedAt) record['prov:startTime'] = activity.startedAt;
|
|
83
|
+
if (activity.endedAt) record['prov:endTime'] = activity.endedAt;
|
|
84
|
+
activities[qname(id)] = record;
|
|
85
|
+
}
|
|
86
|
+
if (Object.keys(activities).length > 0) out.activity = activities;
|
|
87
|
+
|
|
88
|
+
const agents: Record<string, Record<string, unknown>> = {};
|
|
89
|
+
for (const [id, agent] of Object.entries(doc.agents)) {
|
|
90
|
+
const record: Record<string, unknown> = flattenAttrs(agent.attributes);
|
|
91
|
+
record['lineage:id'] = id;
|
|
92
|
+
if (agent.type) record['prov:type'] = agent.type;
|
|
93
|
+
if (agent.name) record['prov:label'] = agent.name;
|
|
94
|
+
agents[qname(id)] = record;
|
|
95
|
+
}
|
|
96
|
+
if (Object.keys(agents).length > 0) out.agent = agents;
|
|
97
|
+
|
|
98
|
+
const wasDerivedFrom: Record<string, Record<string, unknown>> = {};
|
|
99
|
+
const wasGeneratedBy: Record<string, Record<string, unknown>> = {};
|
|
100
|
+
const used: Record<string, Record<string, unknown>> = {};
|
|
101
|
+
const wasAssociatedWith: Record<string, Record<string, unknown>> = {};
|
|
102
|
+
const wasAttributedTo: Record<string, Record<string, unknown>> = {};
|
|
103
|
+
const hadMember: Record<string, Record<string, unknown>> = {};
|
|
104
|
+
const specializationOf: Record<string, Record<string, unknown>> = {};
|
|
105
|
+
const alternateOf: Record<string, Record<string, unknown>> = {};
|
|
106
|
+
|
|
107
|
+
for (const derivation of doc.derivations) {
|
|
108
|
+
const id = derivationId(derivation);
|
|
109
|
+
const genId = qname(`gen:${id}`);
|
|
110
|
+
wasGeneratedBy[genId] = {
|
|
111
|
+
'prov:entity': qname(derivation.output),
|
|
112
|
+
'prov:activity': qname(derivation.activity),
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
for (let i = 0; i < derivation.inputs.length; i++) {
|
|
116
|
+
const input = derivation.inputs[i]!;
|
|
117
|
+
const derId = qname(`wdf:${id}:${i}`);
|
|
118
|
+
wasDerivedFrom[derId] = {
|
|
119
|
+
'prov:generatedEntity': qname(derivation.output),
|
|
120
|
+
'prov:usedEntity': qname(input.entity),
|
|
121
|
+
'prov:activity': qname(derivation.activity),
|
|
122
|
+
...(input.role ? { 'prov:role': input.role } : {}),
|
|
123
|
+
'lineage:effect': input.effect,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const usedId = qname(`used:${id}:${i}`);
|
|
127
|
+
used[usedId] = {
|
|
128
|
+
'prov:activity': qname(derivation.activity),
|
|
129
|
+
'prov:entity': qname(input.entity),
|
|
130
|
+
...(input.role ? { 'prov:role': input.role } : {}),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const [activityId, activity] of Object.entries(doc.activities)) {
|
|
136
|
+
for (let i = 0; i < (activity.associations?.length ?? 0); i++) {
|
|
137
|
+
const association = activity.associations![i]!;
|
|
138
|
+
wasAssociatedWith[qname(`assoc:${activityId}:${i}`)] = {
|
|
139
|
+
'prov:activity': qname(activityId),
|
|
140
|
+
'prov:agent': qname(association.agent),
|
|
141
|
+
...(association.role ? { 'prov:role': association.role } : {}),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (const [entityId, entity] of Object.entries(doc.entities)) {
|
|
147
|
+
for (let i = 0; i < (entity.attributions?.length ?? 0); i++) {
|
|
148
|
+
const attribution = entity.attributions![i]!;
|
|
149
|
+
wasAttributedTo[qname(`attr:${entityId}:${i}`)] = {
|
|
150
|
+
'prov:entity': qname(entityId),
|
|
151
|
+
'prov:agent': qname(attribution.agent),
|
|
152
|
+
...(attribution.role ? { 'prov:role': attribution.role } : {}),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (let i = 0; i < (doc.relations?.length ?? 0); i++) {
|
|
158
|
+
const relation = doc.relations![i]!;
|
|
159
|
+
if (relation.type === 'member_of') {
|
|
160
|
+
// PROV hadMember: collection → member (inverse of our member_of direction)
|
|
161
|
+
hadMember[qname(`mem:${i}`)] = {
|
|
162
|
+
'prov:collection': qname(relation.target),
|
|
163
|
+
'prov:entity': qname(relation.source),
|
|
164
|
+
};
|
|
165
|
+
} else if (relation.type === 'specialization_of') {
|
|
166
|
+
specializationOf[qname(`spec:${i}`)] = {
|
|
167
|
+
'prov:specificEntity': qname(relation.source),
|
|
168
|
+
'prov:generalEntity': qname(relation.target),
|
|
169
|
+
};
|
|
170
|
+
} else if (relation.type === 'alternate_of') {
|
|
171
|
+
alternateOf[qname(`alt:${i}`)] = {
|
|
172
|
+
'prov:alternate1': qname(relation.source),
|
|
173
|
+
'prov:alternate2': qname(relation.target),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (Object.keys(wasDerivedFrom).length > 0) out.wasDerivedFrom = wasDerivedFrom;
|
|
179
|
+
if (Object.keys(wasGeneratedBy).length > 0) out.wasGeneratedBy = wasGeneratedBy;
|
|
180
|
+
if (Object.keys(used).length > 0) out.used = used;
|
|
181
|
+
if (Object.keys(wasAssociatedWith).length > 0) out.wasAssociatedWith = wasAssociatedWith;
|
|
182
|
+
if (Object.keys(wasAttributedTo).length > 0) out.wasAttributedTo = wasAttributedTo;
|
|
183
|
+
if (Object.keys(hadMember).length > 0) out.hadMember = hadMember;
|
|
184
|
+
if (Object.keys(specializationOf).length > 0) out.specializationOf = specializationOf;
|
|
185
|
+
if (Object.keys(alternateOf).length > 0) out.alternateOf = alternateOf;
|
|
186
|
+
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Attributes render alongside canonical PROV keys, so callers write them first
|
|
192
|
+
* and let the canonical keys land last: an attribute named `prov:type` then
|
|
193
|
+
* loses to the real kind instead of falsifying it.
|
|
194
|
+
*
|
|
195
|
+
* Prefixes this document does not declare are folded into `lineage:attribute:*`
|
|
196
|
+
* rather than emitted as undeclared QNames, which loses the profile namespace.
|
|
197
|
+
*/
|
|
198
|
+
function flattenAttrs(attrs: Record<string, unknown> | undefined): Record<string, unknown> {
|
|
199
|
+
const out: Record<string, unknown> = {};
|
|
200
|
+
for (const [key, value] of Object.entries(attrs ?? {})) {
|
|
201
|
+
out[key.startsWith('prov:') || key.startsWith('lineage:') ? key : qname(`attribute:${key}`)] =
|
|
202
|
+
value;
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
package/src/relation.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { AttributesSchema, LineageIdSchema } from './common';
|
|
3
|
+
|
|
4
|
+
export const RELATION_TYPES = ['member_of', 'specialization_of', 'alternate_of'] as const;
|
|
5
|
+
export const RelationTypeSchema = z.enum(RELATION_TYPES);
|
|
6
|
+
export type RelationType = z.infer<typeof RelationTypeSchema>;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Semantic entity–entity relations outside the derivation DAG.
|
|
10
|
+
*
|
|
11
|
+
* - `member_of`: `source` is a member; `target` must be a `collection` entity
|
|
12
|
+
* - `specialization_of`: `source` specializes `target` (PROV specializationOf)
|
|
13
|
+
* - `alternate_of`: `source` and `target` are alternate of each other
|
|
14
|
+
*/
|
|
15
|
+
export const RelationSchema = z
|
|
16
|
+
.object({
|
|
17
|
+
type: RelationTypeSchema,
|
|
18
|
+
source: LineageIdSchema,
|
|
19
|
+
target: LineageIdSchema,
|
|
20
|
+
attributes: AttributesSchema.optional(),
|
|
21
|
+
})
|
|
22
|
+
.strict()
|
|
23
|
+
.superRefine((relation, ctx) => {
|
|
24
|
+
if (relation.source === relation.target) {
|
|
25
|
+
ctx.addIssue({
|
|
26
|
+
code: 'custom',
|
|
27
|
+
message: 'relation source and target must be distinct',
|
|
28
|
+
path: ['target'],
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
export type Relation = z.infer<typeof RelationSchema>;
|
package/src/traverse.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { LineageDocument } from './document';
|
|
2
|
+
|
|
3
|
+
/** Build adjacency: entity → entities derived from it (children / descendants direction). */
|
|
4
|
+
function buildForwardEdges(doc: LineageDocument): Map<string, string[]> {
|
|
5
|
+
const edges = new Map<string, string[]>();
|
|
6
|
+
for (const id of Object.keys(doc.entities)) edges.set(id, []);
|
|
7
|
+
for (const derivation of doc.derivations) {
|
|
8
|
+
if (!(derivation.output in doc.entities)) continue;
|
|
9
|
+
for (const input of derivation.inputs) {
|
|
10
|
+
if (!(input.entity in doc.entities)) continue;
|
|
11
|
+
edges.get(input.entity)!.push(derivation.output);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return edges;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Build adjacency: entity → entities it was derived from (parents / ancestors direction). */
|
|
18
|
+
function buildReverseEdges(doc: LineageDocument): Map<string, string[]> {
|
|
19
|
+
const edges = new Map<string, string[]>();
|
|
20
|
+
for (const id of Object.keys(doc.entities)) edges.set(id, []);
|
|
21
|
+
for (const derivation of doc.derivations) {
|
|
22
|
+
if (!(derivation.output in doc.entities)) continue;
|
|
23
|
+
for (const input of derivation.inputs) {
|
|
24
|
+
if (!(input.entity in doc.entities)) continue;
|
|
25
|
+
edges.get(derivation.output)!.push(input.entity);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return edges;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Topological order of entity ids following derivation dependencies
|
|
33
|
+
* (inputs before outputs). Stable for disconnected nodes by insertion order
|
|
34
|
+
* of `Object.keys(entities)`.
|
|
35
|
+
*/
|
|
36
|
+
export function topologicalEntityOrder(doc: LineageDocument): string[] {
|
|
37
|
+
const forward = buildForwardEdges(doc);
|
|
38
|
+
const indegree = new Map<string, number>();
|
|
39
|
+
for (const id of Object.keys(doc.entities)) indegree.set(id, 0);
|
|
40
|
+
for (const [, targets] of forward) {
|
|
41
|
+
for (const t of targets) {
|
|
42
|
+
indegree.set(t, (indegree.get(t) ?? 0) + 1);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const queue: string[] = [];
|
|
47
|
+
for (const id of Object.keys(doc.entities)) {
|
|
48
|
+
if ((indegree.get(id) ?? 0) === 0) queue.push(id);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const order: string[] = [];
|
|
52
|
+
while (queue.length > 0) {
|
|
53
|
+
const id = queue.shift()!;
|
|
54
|
+
order.push(id);
|
|
55
|
+
for (const next of forward.get(id) ?? []) {
|
|
56
|
+
const nextDeg = (indegree.get(next) ?? 0) - 1;
|
|
57
|
+
indegree.set(next, nextDeg);
|
|
58
|
+
if (nextDeg === 0) queue.push(next);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (order.length !== Object.keys(doc.entities).length) {
|
|
63
|
+
throw new Error('entity dependency graph contains a cycle');
|
|
64
|
+
}
|
|
65
|
+
return order;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function collectReachable(start: string, edges: Map<string, string[]>): string[] {
|
|
69
|
+
if (!edges.has(start)) return [];
|
|
70
|
+
const seen = new Set<string>();
|
|
71
|
+
const stack = [...(edges.get(start) ?? [])];
|
|
72
|
+
while (stack.length > 0) {
|
|
73
|
+
const id = stack.pop()!;
|
|
74
|
+
if (seen.has(id)) continue;
|
|
75
|
+
seen.add(id);
|
|
76
|
+
for (const next of edges.get(id) ?? []) {
|
|
77
|
+
if (!seen.has(next)) stack.push(next);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return [...seen];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** All entities reachable upstream via derivation inputs (transitive). */
|
|
84
|
+
export function entityAncestors(doc: LineageDocument, entityId: string): string[] {
|
|
85
|
+
return collectReachable(entityId, buildReverseEdges(doc));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** All entities reachable downstream via derivation outputs (transitive). */
|
|
89
|
+
export function entityDescendants(doc: LineageDocument, entityId: string): string[] {
|
|
90
|
+
return collectReachable(entityId, buildForwardEdges(doc));
|
|
91
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { derivationId } from './derivation';
|
|
2
|
+
import type { LineageDocument } from './document';
|
|
3
|
+
|
|
4
|
+
export class LineageValidationError extends Error {
|
|
5
|
+
readonly issues: string[];
|
|
6
|
+
|
|
7
|
+
constructor(issues: string[]) {
|
|
8
|
+
super(issues.join('; '));
|
|
9
|
+
this.name = 'LineageValidationError';
|
|
10
|
+
this.issues = issues;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Strict graph validation beyond Zod shape checks.
|
|
16
|
+
*
|
|
17
|
+
* Ensures globally distinct ids, resolvable references, unique derivation
|
|
18
|
+
* outputs (one producer), no self-inputs, acyclic entity dependencies,
|
|
19
|
+
* valid collection membership, and coherent activity timestamps.
|
|
20
|
+
*/
|
|
21
|
+
export function validateLineageGraph(doc: LineageDocument): void {
|
|
22
|
+
const issues: string[] = [];
|
|
23
|
+
|
|
24
|
+
const entityIds = new Set(Object.keys(doc.entities));
|
|
25
|
+
const activityIds = new Set(Object.keys(doc.activities));
|
|
26
|
+
const agentIds = new Set(Object.keys(doc.agents));
|
|
27
|
+
|
|
28
|
+
const idsOfKind = { entity: entityIds, activity: activityIds, agent: agentIds } as const;
|
|
29
|
+
|
|
30
|
+
/** Every cross-reference in a document resolves through here, so none is missed. */
|
|
31
|
+
const requireRef = (kind: keyof typeof idsOfKind, id: string, where: string) => {
|
|
32
|
+
if (!idsOfKind[kind].has(id)) issues.push(`${where}: ${kind} "${id}" does not exist`);
|
|
33
|
+
};
|
|
34
|
+
const requireConfidenceRefs = (
|
|
35
|
+
confidence:
|
|
36
|
+
| { sources?: ReadonlyArray<{ type: keyof typeof idsOfKind; id: string }> }
|
|
37
|
+
| undefined,
|
|
38
|
+
where: string
|
|
39
|
+
) => {
|
|
40
|
+
for (const source of confidence?.sources ?? []) {
|
|
41
|
+
requireRef(source.type, source.id, `${where}.sources`);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Globally distinct IDs across entity / activity / agent maps.
|
|
46
|
+
const claimed = new Map<string, string>();
|
|
47
|
+
for (const [bucket, ids] of [
|
|
48
|
+
['entities', entityIds],
|
|
49
|
+
['activities', activityIds],
|
|
50
|
+
['agents', agentIds],
|
|
51
|
+
] as const) {
|
|
52
|
+
for (const id of ids) {
|
|
53
|
+
const prior = claimed.get(id);
|
|
54
|
+
if (prior) {
|
|
55
|
+
issues.push(`id "${id}" collides across ${prior} and ${bucket}`);
|
|
56
|
+
} else {
|
|
57
|
+
claimed.set(id, bucket);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Derivation ids must be unique and not collide with map keys.
|
|
63
|
+
const derivationIds = new Set<string>();
|
|
64
|
+
const outputProducers = new Map<string, string>();
|
|
65
|
+
|
|
66
|
+
for (let i = 0; i < doc.derivations.length; i++) {
|
|
67
|
+
const derivation = doc.derivations[i]!;
|
|
68
|
+
const path = `derivations[${i}]`;
|
|
69
|
+
|
|
70
|
+
const id = derivationId(derivation);
|
|
71
|
+
|
|
72
|
+
if (derivationIds.has(id)) {
|
|
73
|
+
issues.push(`${path}: duplicate derivation id "${id}"`);
|
|
74
|
+
}
|
|
75
|
+
derivationIds.add(id);
|
|
76
|
+
|
|
77
|
+
if (claimed.has(id)) {
|
|
78
|
+
issues.push(`${path}: derivation id "${id}" collides with ${claimed.get(id)}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!entityIds.has(derivation.output)) {
|
|
82
|
+
issues.push(`${path}: output entity "${derivation.output}" does not exist`);
|
|
83
|
+
} else {
|
|
84
|
+
const prior = outputProducers.get(derivation.output);
|
|
85
|
+
if (prior) {
|
|
86
|
+
issues.push(
|
|
87
|
+
`${path}: entity "${derivation.output}" already has producer derivation "${prior}" (one producer per derived output)`
|
|
88
|
+
);
|
|
89
|
+
} else {
|
|
90
|
+
outputProducers.set(derivation.output, id);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
requireRef('activity', derivation.activity, path);
|
|
95
|
+
|
|
96
|
+
const inputEntities = new Set<string>();
|
|
97
|
+
for (let j = 0; j < derivation.inputs.length; j++) {
|
|
98
|
+
const input = derivation.inputs[j]!;
|
|
99
|
+
const inputPath = `${path}.inputs[${j}]`;
|
|
100
|
+
requireRef('entity', input.entity, inputPath);
|
|
101
|
+
if (input.entity === derivation.output) {
|
|
102
|
+
issues.push(`${inputPath}: output cannot be its own input`);
|
|
103
|
+
}
|
|
104
|
+
if (inputEntities.has(input.entity)) {
|
|
105
|
+
issues.push(`${inputPath}: duplicate input entity "${input.entity}"`);
|
|
106
|
+
}
|
|
107
|
+
inputEntities.add(input.entity);
|
|
108
|
+
requireConfidenceRefs(input.confidence, `${inputPath}.confidence`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
requireConfidenceRefs(derivation.confidence, `${path}.confidence`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Document outputs resolve.
|
|
115
|
+
const seenOutputs = new Set<string>();
|
|
116
|
+
for (let i = 0; i < doc.outputs.length; i++) {
|
|
117
|
+
const outputId = doc.outputs[i]!;
|
|
118
|
+
if (!entityIds.has(outputId)) {
|
|
119
|
+
issues.push(`outputs[${i}]: entity "${outputId}" does not exist`);
|
|
120
|
+
}
|
|
121
|
+
if (seenOutputs.has(outputId)) {
|
|
122
|
+
issues.push(`outputs[${i}]: duplicate entity "${outputId}"`);
|
|
123
|
+
}
|
|
124
|
+
seenOutputs.add(outputId);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Entity attributions, sign-offs, and confidence sources.
|
|
128
|
+
for (const [entityId, entity] of Object.entries(doc.entities)) {
|
|
129
|
+
const entityPath = `entities.${entityId}`;
|
|
130
|
+
entity.attributions?.forEach((attribution, i) => {
|
|
131
|
+
requireRef('agent', attribution.agent, `${entityPath}.attributions[${i}]`);
|
|
132
|
+
});
|
|
133
|
+
entity.approvals?.forEach((approval, i) => {
|
|
134
|
+
requireRef('agent', approval.agent, `${entityPath}.approvals[${i}]`);
|
|
135
|
+
});
|
|
136
|
+
entity.confidence?.forEach((assertion, i) => {
|
|
137
|
+
requireConfidenceRefs(assertion, `${entityPath}.confidence[${i}]`);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Activity associations + timestamp ordering. RFC 3339 syntax is a shape
|
|
142
|
+
// check; this pass still rejects NaN instants if a document bypassed parse.
|
|
143
|
+
for (const [activityId, activity] of Object.entries(doc.activities)) {
|
|
144
|
+
activity.associations?.forEach((association, i) => {
|
|
145
|
+
requireRef('agent', association.agent, `activities.${activityId}.associations[${i}]`);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const { startedAt, endedAt } = activity;
|
|
149
|
+
if (startedAt !== undefined && endedAt !== undefined) {
|
|
150
|
+
const startedMs = Date.parse(startedAt);
|
|
151
|
+
const endedMs = Date.parse(endedAt);
|
|
152
|
+
if (Number.isNaN(startedMs) || Number.isNaN(endedMs)) {
|
|
153
|
+
issues.push(
|
|
154
|
+
`activities.${activityId}: startedAt or endedAt is not a valid RFC 3339 timestamp`
|
|
155
|
+
);
|
|
156
|
+
} else if (endedMs < startedMs) {
|
|
157
|
+
issues.push(`activities.${activityId}: endedAt precedes startedAt`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Relations: resolve + collection membership correctness.
|
|
163
|
+
for (let i = 0; i < doc.relations.length; i++) {
|
|
164
|
+
const relation = doc.relations[i]!;
|
|
165
|
+
const path = `relations[${i}]`;
|
|
166
|
+
requireRef('entity', relation.source, `${path}.source`);
|
|
167
|
+
requireRef('entity', relation.target, `${path}.target`);
|
|
168
|
+
if (
|
|
169
|
+
relation.type === 'member_of' &&
|
|
170
|
+
entityIds.has(relation.target) &&
|
|
171
|
+
doc.entities[relation.target]?.kind !== 'collection'
|
|
172
|
+
) {
|
|
173
|
+
issues.push(`${path}: member_of target "${relation.target}" must be a collection entity`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Entity dependency graph via derivations must be acyclic.
|
|
178
|
+
const dependents = new Map<string, string[]>();
|
|
179
|
+
for (const id of entityIds) dependents.set(id, []);
|
|
180
|
+
for (const derivation of doc.derivations) {
|
|
181
|
+
if (!entityIds.has(derivation.output)) continue;
|
|
182
|
+
for (const input of derivation.inputs) {
|
|
183
|
+
if (!entityIds.has(input.entity)) continue;
|
|
184
|
+
dependents.get(input.entity)!.push(derivation.output);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const visiting = new Set<string>();
|
|
189
|
+
const visited = new Set<string>();
|
|
190
|
+
const cyclePath: string[] = [];
|
|
191
|
+
|
|
192
|
+
const visit = (id: string): boolean => {
|
|
193
|
+
if (visited.has(id)) return false;
|
|
194
|
+
if (visiting.has(id)) {
|
|
195
|
+
cyclePath.push(id);
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
visiting.add(id);
|
|
199
|
+
for (const next of dependents.get(id) ?? []) {
|
|
200
|
+
if (visit(next)) {
|
|
201
|
+
cyclePath.push(id);
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
visiting.delete(id);
|
|
206
|
+
visited.add(id);
|
|
207
|
+
return false;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
for (const id of entityIds) {
|
|
211
|
+
if (visit(id)) {
|
|
212
|
+
const cycle = [...new Set(cyclePath.reverse())].join(' → ');
|
|
213
|
+
issues.push(`entity dependency cycle detected: ${cycle}`);
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (issues.length > 0) {
|
|
219
|
+
throw new LineageValidationError(issues);
|
|
220
|
+
}
|
|
221
|
+
}
|