fix(process): bound retained trace ordering state

Prove total orders with Kahn traversal and use ranks; stream forward and reverse reachability for general partial orders instead of retaining every closure. Aggregate unordered conflicts by declaration while preserving missing-pair counts and independent malformed-input diagnostics.

Validation: 33 focused trace domain and CLI/MCP boundary tests passed. Six bounded Flameox executions preserved answer digests and cycle rejection; combined median peak RSS fell from 391.13 to 127.72 MiB. General partial-order traversal costs more CPU; the experiment records that tradeoff. Full suite and real-provider lanes were not run.
This commit is contained in:
morluto committed 2026-10-13 13:17:44 +08:00
1 parent 16f5ded446
commit 63e796d50e
3 files changed
+394 -105

No files matched your search

@@ -95,8 +95,54 @@ describe("process trace specification", () => {
});
expect(result.language.kind).toBe("partial_order");
});
});
it("counts only undeclared pairs after transitive, not-before and unordered relations", () => {
describe("process trace ordering validation", () => {
it("uses a unique topological order for transitive declarations and conflicts", () => {
const events = ["a", "b", "c"].map((id) => ({
id,
source: "lifecycle" as const,
exact: { marker: id },
cardinality: { kind: "optional" as const },
}));
const accepted = processTraceSpecificationSchema.safeParse({
events,
language: {
kind: "partial_order",
happens_before: [
{ before: "a", after: "b" },
{ before: "b", after: "c" },
],
},
});
expect(accepted.success).toBe(true);
const rejected = processTraceSpecificationSchema.safeParse({
events,
language: {
kind: "partial_order",
happens_before: [
{ before: "a", after: "b" },
{ before: "b", after: "c" },
],
not_before: [{ event: "a", anchor: "c" }],
unordered_groups: [{ events: ["a", "b", "c"] }],
},
});
if (rejected.success) throw new Error("Expected ordered pair conflicts");
expect(rejected.error.issues).toEqual([
expect.objectContaining({
path: ["language", "not_before", 0],
message: "not-before constraint conflicts with happens-before order",
}),
expect.objectContaining({
path: ["language", "unordered_groups", 0],
message: "ordered events cannot also be declared unordered",
}),
]);
});
it("counts only undeclared pairs across transitive and disconnected relations", () => {
const events = ["a", "b", "c", "d"].map((id) => ({
id,
source: "lifecycle" as const,
@@ -150,7 +196,82 @@ describe("process trace specification", () => {
}),
]);
});
});
describe("malformed process trace declarations", () => {
it("preserves independent diagnostics when event ids are duplicated", () => {
const result = processTraceSpecificationSchema.safeParse({
events: [
{
id: "a",
source: "lifecycle",
exact: { fixture: "first" },
cardinality: { kind: "optional" },
},
{
id: "a",
source: "lifecycle",
exact: { fixture: "second" },
cardinality: { kind: "optional" },
},
],
language: {
kind: "partial_order",
unordered_groups: [{ events: ["a", "b"] }],
},
});
expect(result.success).toBe(false);
if (result.success) throw new Error("Expected a duplicate event id");
expect(result.error.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: ["events", 1, "id"],
message: "event ids must be unique",
}),
expect.objectContaining({
path: ["language", "unordered_groups", 0, "events", 1],
message: "unknown event id: b",
}),
]),
);
});
it("keeps reachability diagnostics for unknown referenced successors", () => {
const result = processTraceSpecificationSchema.safeParse({
events: ["a", "b"].map((id) => ({
id,
source: "lifecycle",
exact: { fixture: id },
cardinality: { kind: "optional" },
})),
language: {
kind: "partial_order",
happens_before: [{ before: "a", after: "missing" }],
not_before: [{ event: "a", anchor: "missing" }],
},
});
expect(result.success).toBe(false);
if (result.success) throw new Error("Expected an unknown event reference");
expect(result.error.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: ["language", "happens_before", 0, "after"],
message: "unknown event id: missing",
}),
expect.objectContaining({
path: ["language", "not_before", 0, "anchor"],
message: "unknown event id: missing",
}),
expect.objectContaining({
path: ["language", "not_before", 0],
message: "not-before constraint conflicts with happens-before order",
}),
]),
);
});
});
describe("finite trace specification validation", () => {
it("rejects invalid ignores and unsatisfiable variants", () => {
const base = specification();
expect(
+209 -95
View File
@@ -105,7 +105,17 @@ export type ProcessTraceSpecification = z.infer<
typeof specificationShapeSchema
>;
type PartialOrderLanguage = z.infer<typeof partialOrderLanguageSchema>;
type Reachability = (origin: string) => ReadonlySet<string>;
type AcyclicGraphFacts =
| {
readonly kind: "total-order";
readonly rank: ReadonlyMap<string, number>;
}
| {
readonly kind: "partial-order";
readonly reverseAdjacency: ReadonlyMap<string, ReadonlySet<string>>;
};
type GraphFacts = { readonly kind: "cyclic" } | AcyclicGraphFacts;
export const canonicalTraceJson = (value: unknown): string => {
const serialized = canonicalize(value);
@@ -195,38 +205,28 @@ const validateNotBefore = (
return pairs;
};
const validateNotBeforeOrder = (
language: PartialOrderLanguage,
context: z.RefinementCtx,
reachable: Reachability,
): void => {
for (const [index, constraint] of language.not_before.entries())
if (reachable(constraint.event).has(constraint.anchor))
context.addIssue({
code: "custom",
message: "not-before constraint conflicts with happens-before order",
path: ["language", "not_before", index],
});
};
const graphHasCycle = (
const classifyGraph = (
ids: readonly string[],
adjacency: ReadonlyMap<string, ReadonlySet<string>>,
): boolean => {
): GraphFacts => {
const incoming = new Map(ids.map((id) => [id, 0]));
let hasUnknownSuccessor = false;
for (const successors of adjacency.values())
for (const successor of successors) {
const count = incoming.get(successor);
if (count !== undefined) incoming.set(successor, count + 1);
if (count === undefined) hasUnknownSuccessor = true;
else incoming.set(successor, count + 1);
}
const ready = [...incoming].flatMap(([id, count]) =>
count === 0 ? [id] : [],
);
let examined = 0;
const order: string[] = [];
let unique = true;
while (ready.length > 0) {
if (ready.length !== 1) unique = false;
const current = ready.pop();
if (current === undefined) break;
examined++;
if (current === undefined) continue;
order.push(current);
for (const successor of adjacency.get(current) ?? []) {
const count = incoming.get(successor);
if (count === undefined) continue;
@@ -234,50 +234,67 @@ const graphHasCycle = (
if (count === 1) ready.push(successor);
}
}
return examined !== incoming.size;
if (order.length !== incoming.size) return { kind: "cyclic" };
if (unique && order.length === ids.length && !hasUnknownSuccessor) {
return {
kind: "total-order",
rank: new Map(order.map((id, index) => [id, index])),
};
}
const reverseAdjacency = new Map(
[...incoming.keys()].map((id) => [id, new Set<string>()]),
);
for (const [before, successors] of adjacency)
for (const after of successors) reverseAdjacency.get(after)?.add(before);
return { kind: "partial-order", reverseAdjacency };
};
const createReachability = (
const collectReachable = (
origin: string,
adjacency: ReadonlyMap<string, ReadonlySet<string>>,
): Reachability => {
const reachable = new Map<string, Set<string>>();
return (origin) => {
const cached = reachable.get(origin);
if (cached !== undefined) return cached;
const seen = new Set<string>();
const pending = [...(adjacency.get(origin) ?? [])];
while (pending.length > 0) {
const current = pending.pop();
if (current === undefined || seen.has(current)) continue;
seen.add(current);
for (const successor of adjacency.get(current) ?? [])
pending.push(successor);
}
reachable.set(origin, seen);
return seen;
};
};
const isOrdered = (
reachable: Reachability,
left: string,
right: string,
): boolean => reachable(left).has(right) || reachable(right).has(left);
type OrderingRelations = {
readonly reachable: Reachability;
readonly notBeforePairs: ReadonlySet<string>;
): ReadonlySet<string> => {
const seen = new Set<string>();
const pending = [...(adjacency.get(origin) ?? [])];
while (pending.length > 0) {
const current = pending.pop();
if (current === undefined || seen.has(current)) continue;
seen.add(current);
for (const successor of adjacency.get(current) ?? [])
pending.push(successor);
}
return seen;
};
const validateUnorderedGroups = (
language: PartialOrderLanguage,
context: z.RefinementCtx,
known: ReadonlySet<string>,
): ((left: string, right: string) => boolean) => {
): {
readonly groups: readonly ReadonlySet<string>[];
hasPair(left: string, right: string): boolean;
markDeclaredPairConflicts(
pairs: ReadonlySet<string>,
conflicts: boolean[],
): void;
markSharedGroups(left: string, right: string, conflicts: boolean[]): void;
} => {
const memberships = new Map<string, Set<number>>();
const groups: Set<string>[] = [];
const repeatedMemberships = new Map<string, Set<number>>();
for (const [groupIndex, group] of language.unordered_groups.entries()) {
const path = ["language", "unordered_groups", groupIndex];
const uniqueIds = new Set(group.events);
const uniqueIds = new Set<string>();
const repeatedIds = new Set<string>();
for (const id of group.events) {
if (uniqueIds.has(id)) repeatedIds.add(id);
else uniqueIds.add(id);
}
groups.push(uniqueIds);
for (const id of repeatedIds) {
const groupIndexes = repeatedMemberships.get(id) ?? new Set<number>();
groupIndexes.add(groupIndex);
repeatedMemberships.set(id, groupIndexes);
}
if (uniqueIds.size !== group.events.length)
context.addIssue({
code: "custom",
@@ -301,51 +318,121 @@ const validateUnorderedGroups = (
path,
});
}
return (left, right) => {
const markSharedGroups = (
left: string,
right: string,
conflicts: boolean[],
): void => {
if (left === right) {
for (const groupIndex of repeatedMemberships.get(left) ?? [])
conflicts[groupIndex] = true;
return;
}
const rightGroups = memberships.get(right);
for (const group of memberships.get(left) ?? [])
if (rightGroups?.has(group) === true) return true;
return false;
if (rightGroups?.has(group) === true) conflicts[group] = true;
};
return {
groups,
hasPair(left, right) {
if (left === right) return memberships.has(left);
const rightGroups = memberships.get(right);
for (const group of memberships.get(left) ?? [])
if (rightGroups?.has(group) === true) return true;
return false;
},
markDeclaredPairConflicts(pairs, conflicts) {
for (const key of pairs) {
const separator = key.indexOf("\0");
const left = key.slice(0, separator);
const right = key.slice(separator + 1);
markSharedGroups(left, right, conflicts);
}
},
markSharedGroups,
};
};
const validateUnorderedOrder = (
const validateTotalOrderRelations = (
language: PartialOrderLanguage,
graph: Extract<GraphFacts, { kind: "total-order" }>,
unorderedGroups: ReturnType<typeof validateUnorderedGroups>,
notBeforePairs: ReadonlySet<string>,
context: z.RefinementCtx,
relations: OrderingRelations,
): void => {
for (const [groupIndex, group] of language.unordered_groups.entries())
for (const [leftIndex, leftId] of group.events.entries()) {
for (
let rightIndex = leftIndex + 1;
rightIndex < group.events.length;
rightIndex++
) {
const rightId = group.events[rightIndex];
if (rightId === undefined) continue;
const key = pairKey(leftId, rightId);
if (
isOrdered(relations.reachable, leftId, rightId) ||
relations.notBeforePairs.has(key)
)
context.addIssue({
code: "custom",
message: "ordered events cannot also be declared unordered",
path: ["language", "unordered_groups", groupIndex],
});
for (const [index, constraint] of language.not_before.entries()) {
const before = graph.rank.get(constraint.event);
const after = graph.rank.get(constraint.anchor);
if (before !== undefined && after !== undefined && before < after)
context.addIssue({
code: "custom",
message: "not-before constraint conflicts with happens-before order",
path: ["language", "not_before", index],
});
}
const conflicts = new Array<boolean>(language.unordered_groups.length).fill(
false,
);
for (const [groupIndex, group] of unorderedGroups.groups.entries()) {
let comparableEvents = 0;
for (const id of group)
if (graph.rank.has(id) && ++comparableEvents === 2) {
conflicts[groupIndex] = true;
break;
}
}
}
unorderedGroups.markDeclaredPairConflicts(notBeforePairs, conflicts);
addUnorderedConflictIssues(conflicts, context);
};
const validateCompletePairDeclarations = (
const addUnorderedConflictIssues = (
conflicts: readonly boolean[],
context: z.RefinementCtx,
): void => {
for (const [groupIndex, conflict] of conflicts.entries())
if (conflict)
context.addIssue({
code: "custom",
message: "ordered events cannot also be declared unordered",
path: ["language", "unordered_groups", groupIndex],
});
};
const validatePartialOrderRelations = (
ids: readonly string[],
relations: OrderingRelations,
unorderedPair: (left: string, right: string) => boolean,
language: PartialOrderLanguage,
adjacency: ReadonlyMap<string, ReadonlySet<string>>,
graph: Extract<GraphFacts, { kind: "partial-order" }>,
unorderedGroups: ReturnType<typeof validateUnorderedGroups>,
notBeforePairs: ReadonlySet<string>,
context: z.RefinementCtx,
): void => {
let missingPairCount = 0;
let firstMissingPair = "";
const groupConflicts = new Array<boolean>(
language.unordered_groups.length,
).fill(false);
unorderedGroups.markDeclaredPairConflicts(notBeforePairs, groupConflicts);
const notBeforeConflictFlags = new Uint8Array(language.not_before.length);
const constraintsBySource = new Map<string, number[]>();
for (const [index, constraint] of language.not_before.entries()) {
const indexes = constraintsBySource.get(constraint.event) ?? [];
indexes.push(index);
constraintsBySource.set(constraint.event, indexes);
}
// Keep only one origin's forward and backward closure live at a time. Pair
// iteration stays in declaration order so the count and first missing pair
// retain the established diagnostic semantics.
for (const [leftIndex, leftId] of ids.entries()) {
const forward = collectReachable(leftId, adjacency);
const backward = collectReachable(leftId, graph.reverseAdjacency);
for (const constraintIndex of constraintsBySource.get(leftId) ?? []) {
const constraint = language.not_before[constraintIndex];
if (constraint !== undefined && forward.has(constraint.anchor))
notBeforeConflictFlags[constraintIndex] = 1;
}
for (
let rightIndex = leftIndex + 1;
rightIndex < ids.length;
@@ -353,17 +440,32 @@ const validateCompletePairDeclarations = (
) {
const rightId = ids[rightIndex];
if (rightId === undefined) continue;
if (isOrdered(relations.reachable, leftId, rightId)) continue;
const sameEvent = leftId === rightId;
const ordered =
!sameEvent && (forward.has(rightId) || backward.has(rightId));
const key = pairKey(leftId, rightId);
if (
!relations.notBeforePairs.has(key) &&
!unorderedPair(leftId, rightId)
) {
const notBefore = notBeforePairs.has(key);
const unordered = unorderedGroups.hasPair(leftId, rightId);
if (ordered || notBefore) {
if (!sameEvent)
unorderedGroups.markSharedGroups(leftId, rightId, groupConflicts);
}
if (!ordered && !notBefore && !unordered) {
if (missingPairCount === 0) firstMissingPair = `${leftId}/${rightId}`;
missingPairCount++;
}
}
}
for (const [index, conflict] of notBeforeConflictFlags.entries())
if (conflict === 1)
context.addIssue({
code: "custom",
message: "not-before constraint conflicts with happens-before order",
path: ["language", "not_before", index],
});
addUnorderedConflictIssues(groupConflicts, context);
if (missingPairCount > 0)
context.addIssue({
code: "custom",
@@ -383,31 +485,43 @@ const validatePartialOrder = (
const ids = specification.events.map(({ id }) => id);
const known = new Set(ids);
const adjacency = buildAdjacency(specification.language, context, known);
const cyclic = graphHasCycle(ids, adjacency);
if (cyclic)
const graph = classifyGraph(ids, adjacency);
if (graph.kind === "cyclic")
context.addIssue({
code: "custom",
message: "happens-before graph must be acyclic",
path: ["language"],
});
const reachable = createReachability(adjacency);
const notBeforePairs = validateNotBefore(
specification.language,
context,
known,
);
const relations = { reachable, notBeforePairs };
const unorderedPair = validateUnorderedGroups(
const unorderedGroups = validateUnorderedGroups(
specification.language,
context,
known,
);
// Preserve independent declaration diagnostics on cyclic input. Computing
// all pair relations cannot establish a valid partial order in that case.
if (!cyclic) {
validateNotBeforeOrder(specification.language, context, reachable);
validateUnorderedOrder(specification.language, context, relations);
validateCompletePairDeclarations(ids, relations, unorderedPair, context);
if (graph.kind === "total-order") {
validateTotalOrderRelations(
specification.language,
graph,
unorderedGroups,
notBeforePairs,
context,
);
} else if (graph.kind === "partial-order") {
validatePartialOrderRelations(
ids,
specification.language,
adjacency,
graph,
unorderedGroups,
notBeforePairs,
context,
);
}
for (const [kind, values] of [
["prefix", specification.language.prefix],