Skip to content

Commit 209e250

Browse files
philcunliffeclaude
andcommitted
Shared-scan graph projection: one scan per contract, declarative predicates (LLP 0095/0096)
projectGraph ran every contract rule as an independent full SQL scan with refresh:'always', and the LLP 0026 aux filter prepended `attributes` to each rule and JSON-parsed it per rule per row. At 25 rules that meant 25 full table sweeps per projection: ~35GB read and 45+ minutes for a ~1.3GB source on a production hypaware-server deployment (LLP 0095). - project.js: one shared scan per contract over the union of declarative rules' columns; per-rule where predicates (eq/in/likePrefix, SQL null semantics) evaluated in JS; raw-SQL rules stay supported, run standalone, grouped by identical SQL text. - Contract gains rowFilter, evaluated once per row on both paths; the ai-gateway contract's aux filter moves there. - ai-gateway-graph: 21 of 25 rules migrated to columns/where; the two content_text prefix surfaces (x node+edge) stay raw SQL so the table's largest column keeps its server-side pushdown. - Registry validates the new shapes; equivalence test pins the JS evaluator to the SQL engine's semantics over a fixture hitting every predicate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 879184e commit 209e250

10 files changed

Lines changed: 574 additions & 123 deletions

hypaware-core/plugins-workspace/ai-gateway-graph/src/graph_contract.js

Lines changed: 68 additions & 57 deletions
Large diffs are not rendered by default.

hypaware-core/plugins-workspace/ai-gateway-graph/src/types.d.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,24 @@
33
/** A materialized graph row (node or edge), keyed by column name. */
44
export type GraphRow = Record<string, unknown>
55

6-
/** One T0 contract rule: a read-only SELECT plus a row mapper. */
6+
/** A declarative rule filter (LLP 0096): AND of eq / in / likePrefix, SQL null semantics. */
7+
export interface RulePredicate {
8+
eq?: Record<string, string>
9+
in?: Record<string, string[]>
10+
likePrefix?: Record<string, string>
11+
}
12+
13+
/**
14+
* One T0 contract rule: a source read plus a row mapper. Declarative
15+
* `columns` (+ optional `where`) joins the contract's shared scan; raw `sql`
16+
* runs standalone (LLP 0096). Exactly one of the two.
17+
*/
718
export interface ContractRule {
819
kind: 'node' | 'edge'
920
type: string
10-
sql: string
21+
sql?: string
22+
columns?: string[]
23+
where?: RulePredicate
1124
toRow(row: Record<string, unknown>): GraphRow | null
1225
}
1326

hypaware-core/plugins-workspace/context-graph/src/contract-registry.js

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,24 @@ export function createContractRegistry(opts = {}) {
4848
throw new TypeError(`registerContract: '${contract.name}' rules must be a non-empty array`)
4949
}
5050
// Validate each rule's shape at registration, not at projection time: the
51-
// engine reads `kind`/`sql`/`toRow` directly (project.js) and routes by
52-
// `kind`, so a connector typo would otherwise surface as a confusing
53-
// mid-projection failure (or silently route rows into the wrong target
54-
// map) far from the contract that caused it.
51+
// engine reads `kind`/`sql`/`columns`/`where`/`toRow` directly
52+
// (project.js) and routes by `kind`, so a connector typo would otherwise
53+
// surface as a confusing mid-projection failure (or silently route rows
54+
// into the wrong target map) far from the contract that caused it.
55+
// @ref LLP 0096#decision [implements]: exactly one read form per rule; `where` only rides `columns`; raw SQL must carry the rowFilter's columns itself
56+
if (contract.rowFilter !== undefined) {
57+
const filter = contract.rowFilter
58+
const at = `'${contract.name}' rowFilter`
59+
if (!filter || typeof filter !== 'object') {
60+
throw new TypeError(`registerContract: ${at} must be an object`)
61+
}
62+
if (!Array.isArray(filter.columns) || filter.columns.length === 0 || filter.columns.some((c) => typeof c !== 'string' || c.length === 0)) {
63+
throw new TypeError(`registerContract: ${at} columns must be non-empty strings`)
64+
}
65+
if (typeof filter.keep !== 'function') {
66+
throw new TypeError(`registerContract: ${at} keep must be a function`)
67+
}
68+
}
5569
contract.rules.forEach((rule, i) => {
5670
const at = `'${contract.name}' rule ${i}`
5771
if (!rule || typeof rule !== 'object') {
@@ -63,8 +77,26 @@ export function createContractRegistry(opts = {}) {
6377
if (typeof rule.type !== 'string' || rule.type.length === 0) {
6478
throw new TypeError(`registerContract: ${at} type must be a non-empty string`)
6579
}
66-
if (typeof rule.sql !== 'string' || rule.sql.length === 0) {
67-
throw new TypeError(`registerContract: ${at} sql must be a non-empty string`)
80+
const hasSql = typeof rule.sql === 'string' && rule.sql.length > 0
81+
const hasColumns = Array.isArray(rule.columns)
82+
if (hasSql === hasColumns) {
83+
throw new TypeError(`registerContract: ${at} must carry exactly one of sql or columns`)
84+
}
85+
if (hasColumns) {
86+
const cols = /** @type {unknown[]} */ (rule.columns)
87+
if (cols.length === 0 || cols.some((c) => typeof c !== 'string' || c.length === 0)) {
88+
throw new TypeError(`registerContract: ${at} columns must be non-empty strings`)
89+
}
90+
if (rule.where !== undefined) validatePredicate(rule.where, at)
91+
} else if (rule.where !== undefined) {
92+
throw new TypeError(`registerContract: ${at} where is only valid with columns`)
93+
}
94+
if (hasSql && contract.rowFilter) {
95+
for (const col of contract.rowFilter.columns) {
96+
if (!rule.sql?.includes(col)) {
97+
throw new TypeError(`registerContract: ${at} raw sql must select rowFilter column '${col}'`)
98+
}
99+
}
68100
}
69101
if (typeof rule.toRow !== 'function') {
70102
throw new TypeError(`registerContract: ${at} toRow must be a function`)
@@ -86,6 +118,48 @@ export function createContractRegistry(opts = {}) {
86118
})
87119
}
88120

121+
/**
122+
* A `where` must be built from the three supported predicate shapes only,
123+
* with the value types the JS evaluator expects: anything else would
124+
* silently match nothing at projection time.
125+
*
126+
* @param {unknown} where
127+
* @param {string} at
128+
*/
129+
function validatePredicate(where, at) {
130+
if (!where || typeof where !== 'object') {
131+
throw new TypeError(`registerContract: ${at} where must be an object`)
132+
}
133+
const w = /** @type {Record<string, unknown>} */ (where)
134+
for (const key of Object.keys(w)) {
135+
if (key !== 'eq' && key !== 'in' && key !== 'likePrefix') {
136+
throw new TypeError(`registerContract: ${at} where.${key} is not a supported predicate (eq, in, likePrefix)`)
137+
}
138+
}
139+
for (const shape of ['eq', 'likePrefix']) {
140+
const block = w[shape]
141+
if (block === undefined) continue
142+
if (!block || typeof block !== 'object') {
143+
throw new TypeError(`registerContract: ${at} where.${shape} must be an object`)
144+
}
145+
for (const [col, value] of Object.entries(block)) {
146+
if (typeof value !== 'string' || value.length === 0) {
147+
throw new TypeError(`registerContract: ${at} where.${shape}.${col} must be a non-empty string`)
148+
}
149+
}
150+
}
151+
if (w.in !== undefined) {
152+
if (!w.in || typeof w.in !== 'object') {
153+
throw new TypeError(`registerContract: ${at} where.in must be an object`)
154+
}
155+
for (const [col, list] of Object.entries(w.in)) {
156+
if (!Array.isArray(list) || list.length === 0 || list.some((v) => typeof v !== 'string' || v.length === 0)) {
157+
throw new TypeError(`registerContract: ${at} where.in.${col} must be a non-empty array of strings`)
158+
}
159+
}
160+
}
161+
}
162+
89163
/**
90164
* All registered contracts, name-sorted so projection order is stable.
91165
* @returns {Contract[]}

hypaware-core/plugins-workspace/context-graph/src/project.js

Lines changed: 122 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,33 +45,97 @@ export async function projectGraph({ query, storage, contracts, config, dryRun =
4545
/** @type {Map<string, GraphRow>} */
4646
const edges = new Map()
4747

48+
/**
49+
* Feed one source row through a set of rules into the node/edge maps.
50+
* Merge is order-independent (`mergeRow`), so fanning a shared scan's
51+
* row out to many rules is observationally identical to the old
52+
* one-query-per-rule order.
53+
*
54+
* @param {Record<string, unknown>} row
55+
* @param {import('./types.js').ContractRule[]} rules
56+
*/
57+
const applyRules = (row, rules) => {
58+
for (const rule of rules) {
59+
if (rule.where && !matchesPredicate(rule.where, row)) continue
60+
const built = rule.toRow(row)
61+
if (!built) continue
62+
const target = rule.kind === 'node' ? nodes : edges
63+
const idKey = rule.kind === 'node' ? 'node_id' : 'edge_id'
64+
const id = /** @type {string} */ (built[idKey])
65+
const existing = target.get(id)
66+
if (existing) mergeRow(existing, built)
67+
else target.set(id, built)
68+
}
69+
}
70+
4871
let sourceRows = 0
72+
let scanCount = 0
4973
for (const contract of contracts) {
50-
for (const rule of contract.rules) {
74+
const keep = contract.rowFilter?.keep
75+
const declarative = contract.rules.filter((rule) => rule.columns)
76+
const raw = contract.rules.filter((rule) => typeof rule.sql === 'string')
77+
78+
// One shared scan per contract: the union of the declarative rules'
79+
// columns (plus the row filter's), each rule's predicate evaluated
80+
// in JS per row. This is the whole LLP 0095 fix: rule count no
81+
// longer multiplies table scans, and the contract row filter runs
82+
// once per row instead of once per rule per row.
83+
// @ref LLP 0096#decision [implements]: one scan per contract; JS predicates with SQL null semantics; rowFilter once per row
84+
if (declarative.length > 0) {
85+
const columns = new Set()
86+
for (const rule of declarative) {
87+
for (const col of rule.columns ?? []) columns.add(col)
88+
for (const col of predicateColumns(rule.where)) columns.add(col)
89+
}
90+
for (const col of contract.rowFilter?.columns ?? []) columns.add(col)
5191
const result = await executeQuerySql({
52-
query: rule.sql,
92+
query: `SELECT ${[...columns].sort().join(', ')} FROM ${contract.sourceDataset}`,
5393
registry: query,
5494
storage,
5595
config,
5696
refresh: 'always',
5797
})
5898
sourceRows += result.rows.length
59-
const target = rule.kind === 'node' ? nodes : edges
60-
const idKey = rule.kind === 'node' ? 'node_id' : 'edge_id'
99+
scanCount += 1
100+
for (const row of result.rows) {
101+
if (keep && !keep(row)) continue
102+
applyRules(row, declarative)
103+
}
104+
}
105+
106+
// Raw-SQL rules run standalone, grouped by identical SQL text so
107+
// rule pairs sharing a query (a surface's node + edge rule) cost one
108+
// scan. Their SQL already selects the rowFilter's columns (registry
109+
// enforced), so the filter applies here too.
110+
/** @type {Map<string, import('./types.js').ContractRule[]>} */
111+
const bySql = new Map()
112+
for (const rule of raw) {
113+
const sql = /** @type {string} */ (rule.sql)
114+
const group = bySql.get(sql)
115+
if (group) group.push(rule)
116+
else bySql.set(sql, [rule])
117+
}
118+
for (const [sql, rules] of bySql) {
119+
const result = await executeQuerySql({
120+
query: sql,
121+
registry: query,
122+
storage,
123+
config,
124+
refresh: 'always',
125+
})
126+
sourceRows += result.rows.length
127+
scanCount += 1
61128
for (const row of result.rows) {
62-
const built = rule.toRow(row)
63-
if (!built) continue
64-
const id = /** @type {string} */ (built[idKey])
65-
const existing = target.get(id)
66-
if (existing) mergeRow(existing, built)
67-
else target.set(id, built)
129+
if (keep && !keep(row)) continue
130+
applyRules(row, rules)
68131
}
69132
}
70133
}
71134

72135
const nodeRows = [...nodes.values()]
73136
const edgeRows = [...edges.values()]
74137
span.setAttribute('contract_count', contracts.length)
138+
span.setAttribute('scan_count', scanCount)
75139
span.setAttribute('source_row_count', sourceRows)
76140
span.setAttribute('node_count', nodeRows.length)
77141
span.setAttribute('edge_count', edgeRows.length)
@@ -144,6 +208,54 @@ async function dedupExisting(rows, idCol, dataset, query, storage, config) {
144208
return rows.filter((r) => !seen.has(/** @type {string} */ (r[idCol])))
145209
}
146210

211+
/**
212+
* Evaluate a rule's declarative predicate against one source row with SQL
213+
* semantics: every clause must hold, and a null or absent column never
214+
* matches (`WHERE col = 'x'` skips null rows; so does this).
215+
*
216+
* @param {import('./types.js').RulePredicate} where
217+
* @param {Record<string, unknown>} row
218+
* @returns {boolean}
219+
* @ref LLP 0096#decision [implements]: eq/in/likePrefix only, AND-composed, null never matches
220+
*/
221+
function matchesPredicate(where, row) {
222+
if (where.eq) {
223+
for (const [col, value] of Object.entries(where.eq)) {
224+
if (row[col] !== value) return false
225+
}
226+
}
227+
if (where.in) {
228+
for (const [col, values] of Object.entries(where.in)) {
229+
const v = row[col]
230+
if (typeof v !== 'string' || !values.includes(v)) return false
231+
}
232+
}
233+
if (where.likePrefix) {
234+
for (const [col, prefix] of Object.entries(where.likePrefix)) {
235+
const v = row[col]
236+
if (typeof v !== 'string' || !v.startsWith(prefix)) return false
237+
}
238+
}
239+
return true
240+
}
241+
242+
/**
243+
* The columns a predicate reads, so the shared scan selects them even when
244+
* no rule's `columns` lists them (a rule may filter on a column `toRow`
245+
* never touches).
246+
*
247+
* @param {import('./types.js').RulePredicate | undefined} where
248+
* @returns {string[]}
249+
*/
250+
function predicateColumns(where) {
251+
if (!where) return []
252+
return [
253+
...Object.keys(where.eq ?? {}),
254+
...Object.keys(where.in ?? {}),
255+
...Object.keys(where.likePrefix ?? {}),
256+
]
257+
}
258+
147259
/**
148260
* True when a dedup query failed because the dataset is not queryable
149261
* yet (unregistered, or its backing path is absent) rather than because

hypaware-core/plugins-workspace/context-graph/src/types.d.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,37 @@ export interface SkippedPartition {
1616
reason: 'unreadable-cursor' | 'unexpected-layout' | 'concurrent-write'
1717
}
1818

19-
/** One T0 contract rule: a read-only SELECT plus a row mapper. */
19+
/**
20+
* A declarative rule filter: the conjunction of the three predicate shapes
21+
* contract rules actually use, evaluated in JS against the contract's one
22+
* shared scan with SQL semantics (a null or absent column never matches).
23+
* No general SQL parsing, no expression language (LLP 0096 §1).
24+
*/
25+
export interface RulePredicate {
26+
/** column = value */
27+
eq?: Record<string, string>
28+
/** column IN (values) */
29+
in?: Record<string, string[]>
30+
/** column LIKE 'prefix%' */
31+
likePrefix?: Record<string, string>
32+
}
33+
34+
/**
35+
* One T0 contract rule: a source read plus a row mapper. Exactly one read
36+
* form: declarative `columns` (+ optional `where`) joins the contract's one
37+
* shared scan; raw `sql` runs standalone, for contracts that migrate on
38+
* their own schedule or predicates that must prune a heavy column
39+
* server-side (LLP 0096 §2-3).
40+
*/
2041
export interface ContractRule {
2142
kind: 'node' | 'edge'
2243
type: string
23-
sql: string
44+
/** Raw SELECT escape hatch; mutually exclusive with `columns`. Must select the contract rowFilter's columns itself. */
45+
sql?: string
46+
/** Columns `toRow` reads; the shared scan selects the union across rules. Mutually exclusive with `sql`. */
47+
columns?: string[]
48+
/** Declarative filter; only valid with `columns`. */
49+
where?: RulePredicate
2450
toRow(row: Record<string, unknown>): GraphRow | null
2551
}
2652

@@ -82,6 +108,16 @@ export interface Contract {
82108
projectorVersion: number
83109
/** The node/edge rules the engine runs for this source. */
84110
rules: ContractRule[]
111+
/**
112+
* Optional source-row filter evaluated once per row before any rule's
113+
* `toRow`, on both the shared-scan and raw-SQL paths (LLP 0096 §4). The
114+
* engine adds `columns` to the shared scan; raw-SQL rules must select
115+
* them in their own SQL (the registry enforces this textually).
116+
*/
117+
rowFilter?: {
118+
columns: string[]
119+
keep(row: Record<string, unknown>): boolean
120+
}
85121
}
86122

87123
/** The in-plugin registry source plugins contribute contracts into. */

0 commit comments

Comments
 (0)