/* [RFC-0192] Axis-generic eligibility engine. The legacy foerderung matrix generalized over an arbitrary record set and a declarative axis→field binding: enumerate candidate tuples per depth, count matching active records, derive live / noindex / redirect-stub decisions, and expose live children / siblings for internal-link grids. Pure — no I/O. Do not build URLs or blocks (the Blueprint provider does). Do not read content (the kernel command supplies records + universes). RFC-0192: initial axis-generic engine. */ import type { AxisTuple, EligibilityPolicy, IndexDecision, SurfaceAxis, SurfaceRecord, } from "./types.ts"; /** Axis id → the record field carrying that axis's membership value(s). */ export type AxisFieldMap = Readonly>; /** Pre-built index: axisId → value → Set. */ export interface RecordIndex { byAxisValue: ReadonlyMap>>; records: readonly SurfaceRecord[]; } /** A single resolved combination at one depth. */ export interface MatrixEntry { depth: number; tuple: AxisTuple; recordCount: number; indexable: boolean; noindex: boolean; decision: IndexDecision; } export interface EligibilityMatrix { /** All emitted entries (live + shallow stubs), ordered by depth then enumeration order. */ entries: MatrixEntry[]; /** pathKey → entry, for ancestor/child/sibling lookups. */ byKey: Map; /** depth → set of live pathKeys. */ liveKeysByDepth: Map>; axisOrder: readonly string[]; } const DEFAULT_MAX_STUB_DEPTH = 1; /** Lowercase, dash-only normalization. Throws on characters outside the policy pattern. */ export function normalizeSegment(value: string, pattern: RegExp): string { const trimmed = value.trim().toLowerCase().replace(/_/g, "-"); if (!pattern.test(trimmed)) { throw new Error(`Invalid surface URL segment: "${value}" (normalized: "${trimmed}")`); } return trimmed; } /** True when `value` (string or array) includes the wanted axis value. */ function fieldIncludes(value: SurfaceRecord[string], wanted: string): boolean { if (Array.isArray(value)) return value.includes(wanted); return value === wanted; } /** * Axis-generic membership test: a record matches a partial tuple when, for every constrained axis, * the record's bound field carries the chosen value. An unconstrained axis imposes no filter. */ export function matchesRecord( record: SurfaceRecord, tuple: AxisTuple, axisFieldMap: AxisFieldMap, ): boolean { for (const [axisId, wanted] of Object.entries(tuple)) { if (wanted === undefined) continue; const field = axisFieldMap[axisId]; if (!field) return false; if (!fieldIncludes(record[field], wanted)) return false; } return true; } /** Count active records matching the tuple. */ export function countMatching( records: readonly SurfaceRecord[], tuple: AxisTuple, axisFieldMap: AxisFieldMap, ): number { let count = 0; for (const record of records) { if (record.status === "archived") continue; if (matchesRecord(record, tuple, axisFieldMap)) count += 1; } return count; } /** Build a per-axis-value index so countMatching can run in intersection time instead of linear time. */ export function buildRecordIndex( records: readonly SurfaceRecord[], axisFieldMap: AxisFieldMap, ): RecordIndex { const byAxisValue = new Map>>(); for (let i = 0; i < records.length; i += 1) { const record = records[i]; if (record.status === "archived") continue; for (const [axisId, field] of Object.entries(axisFieldMap)) { const value = record[field]; if (value === undefined) continue; let axisMap = byAxisValue.get(axisId); if (!axisMap) { axisMap = new Map>(); byAxisValue.set(axisId, axisMap); } if (Array.isArray(value)) { for (const v of value) { let set = axisMap.get(v); if (!set) { set = new Set(); axisMap.set(v, set); } set.add(i); } } else { let set = axisMap.get(value); if (!set) { set = new Set(); axisMap.set(value, set); } set.add(i); } } } // Freeze inner structures so callers cannot mutate them. const frozenByAxisValue = new Map>>(); for (const [axisId, axisMap] of byAxisValue) { const frozenAxisMap = new Map>(); for (const [value, set] of axisMap) { frozenAxisMap.set(value, set); } frozenByAxisValue.set(axisId, frozenAxisMap); } return { byAxisValue: frozenByAxisValue, records }; } /** O(k) count using the pre-built index, where k is the number of matching records. */ export function countMatchingIndexed( tuple: AxisTuple, axisFieldMap: AxisFieldMap, index: RecordIndex, ): number { let candidates: Set | undefined; for (const [axisId, wanted] of Object.entries(tuple)) { if (wanted === undefined) continue; const field = axisFieldMap[axisId]; if (!field) return 0; const axisMap = index.byAxisValue.get(axisId); if (!axisMap) return 0; const set = axisMap.get(wanted); if (!set) return 0; if (candidates === undefined) { candidates = new Set(set); } else { for (const idx of candidates) { if (!set.has(idx)) candidates.delete(idx); } if (candidates.size === 0) return 0; } } if (candidates === undefined) { // No axis constrained — count all active records. let count = 0; for (const record of index.records) { if (record.status !== "archived") count += 1; } return count; } let count = 0; for (const idx of candidates) { const record = index.records[idx]; if (record && record.status !== "archived") count += 1; } return count; } /** Stable key for a (depth, tuple) pair. */ export function pathKey(depth: number, tuple: AxisTuple, axisOrder: readonly string[]): string { const parts: string[] = [String(depth)]; for (let i = 0; i < depth; i += 1) { parts.push(tuple[axisOrder[i]!] ?? ""); } return parts.join("|"); } /** Enumerate every candidate tuple at `depth` (cartesian over the first `depth` axes' universes). */ export function* enumerateCandidateTuples( axes: readonly SurfaceAxis[], depth: number, ): Generator { if (depth === 0) { yield {}; return; } const head = axes.slice(0, depth); const indices = new Array(depth).fill(0); const universes = head.map((axis) => axis.universe); // Guard against an empty universe producing an infinite/empty product. if (universes.some((u) => u.length === 0)) return; for (;;) { const tuple: AxisTuple = {}; for (let i = 0; i < depth; i += 1) tuple[head[i]!.id] = universes[i]![indices[i]!]; yield tuple; let carry = depth - 1; while (carry >= 0) { indices[carry] += 1; if (indices[carry] < universes[carry]!.length) break; indices[carry] = 0; carry -= 1; } if (carry < 0) break; } } function deriveDecision( depth: number, recordCount: number, policy: EligibilityPolicy, forceNonIndexable: boolean, ): IndexDecision { const min = policy.minRecordsPerDepth[depth] ?? 1; const recordGate = recordCount >= min; const noindexThreshold = policy.noindexBelowPerDepth[depth]; const noindex = recordGate && noindexThreshold !== undefined && recordCount < noindexThreshold; if (recordGate && forceNonIndexable) { // RFC-0240: a depth gated behind the regional-hub-or-higher tier — the record gate passed but // the resolved entitlement tier has not unlocked this depth (e.g. d3 region hubs on the base tier). return { recordGate, indexable: false, noindex: true, reason: "regional-gated" }; } return { recordGate, indexable: recordGate, noindex, ...(recordGate ? {} : { reason: "too-few-records" as const }), }; } /** * Build the eligibility matrix. Shallow empty combinations (depth ≤ maxStubDepth) are emitted as * non-live stubs (for 301 redirects); deeper empty combinations are dropped so the build graph * stays bounded. */ export function buildEligibilityMatrix( axes: readonly SurfaceAxis[], axisFieldMap: AxisFieldMap, records: readonly SurfaceRecord[], policy: EligibilityPolicy, options?: { maxDepth?: number; maxStubDepth?: number; /** RFC-0240: depths forced non-indexable (dropped) unless the resolved entitlement tier unlocks them. */ forceNonIndexableDepths?: readonly number[]; }, ): EligibilityMatrix { const axisOrder = axes.map((axis) => axis.id); const maxDepth = options?.maxDepth ?? axes.length; const maxStubDepth = options?.maxStubDepth ?? DEFAULT_MAX_STUB_DEPTH; const forceNonIndexableDepths = new Set(options?.forceNonIndexableDepths ?? []); const entries: MatrixEntry[] = []; const byKey = new Map(); const liveKeysByDepth = new Map>(); const recordIndex = buildRecordIndex(records, axisFieldMap); for (let depth = 0; depth <= maxDepth; depth += 1) { const liveSet = new Set(); liveKeysByDepth.set(depth, liveSet); for (const tuple of enumerateCandidateTuples(axes, depth)) { const recordCount = countMatchingIndexed(tuple, axisFieldMap, recordIndex); const decision = deriveDecision( depth, recordCount, policy, forceNonIndexableDepths.has(depth), ); if (!decision.indexable && depth > maxStubDepth) continue; const entry: MatrixEntry = { depth, tuple, recordCount, indexable: decision.indexable, noindex: decision.noindex, decision, }; const key = pathKey(depth, tuple, axisOrder); entries.push(entry); byKey.set(key, entry); if (decision.indexable) liveSet.add(key); } } return { entries, byKey, liveKeysByDepth, axisOrder }; } /** Walk shallower, dropping one axis at a time, to the nearest live ancestor (or root). */ export function nearestLiveAncestor( entry: MatrixEntry, matrix: EligibilityMatrix, policy: EligibilityPolicy, ): MatrixEntry | null { if (policy.redirectPolicy === "root") { return matrix.byKey.get(pathKey(0, {}, matrix.axisOrder)) ?? null; } for (let depth = entry.depth - 1; depth >= 0; depth -= 1) { const tuple: AxisTuple = {}; for (let i = 0; i < depth; i += 1) { const axisId = matrix.axisOrder[i]!; if (entry.tuple[axisId] !== undefined) tuple[axisId] = entry.tuple[axisId]; } const key = pathKey(depth, tuple, matrix.axisOrder); if (matrix.liveKeysByDepth.get(depth)?.has(key)) return matrix.byKey.get(key) ?? null; } return matrix.byKey.get(pathKey(0, {}, matrix.axisOrder)) ?? null; } /** Live entries one level deeper that extend the given parent tuple. */ export function liveChildrenOf(parent: MatrixEntry, matrix: EligibilityMatrix): MatrixEntry[] { const childDepth = parent.depth + 1; const liveKeys = matrix.liveKeysByDepth.get(childDepth); if (!liveKeys) return []; const results: MatrixEntry[] = []; for (const key of liveKeys) { const entry = matrix.byKey.get(key); if (!entry) continue; const matchesParent = matrix.axisOrder.slice(0, parent.depth).every((axisId) => { const pv = parent.tuple[axisId]; return pv === undefined || pv === entry.tuple[axisId]; }); if (matchesParent) results.push(entry); } return results; } /** Live entries at the same depth sharing the same parent tuple. */ export function liveSiblingsOf(entry: MatrixEntry, matrix: EligibilityMatrix): MatrixEntry[] { if (entry.depth === 0) return []; const parentDepth = entry.depth - 1; const parentTuple: AxisTuple = {}; for (let i = 0; i < parentDepth; i += 1) { const axisId = matrix.axisOrder[i]!; if (entry.tuple[axisId] !== undefined) parentTuple[axisId] = entry.tuple[axisId]; } const parentEntry: MatrixEntry = { depth: parentDepth, tuple: parentTuple, recordCount: 0, indexable: true, noindex: false, decision: { recordGate: true, indexable: true, noindex: false }, }; return liveChildrenOf(parentEntry, matrix).filter( (sibling) => pathKey(sibling.depth, sibling.tuple, matrix.axisOrder) !== pathKey(entry.depth, entry.tuple, matrix.axisOrder), ); }