Skip to content

Commit aba87df

Browse files
harden resolver identity detection and dashboard cleanup
1 parent 8b7caf6 commit aba87df

3 files changed

Lines changed: 63 additions & 6 deletions

File tree

engraphis/core/resolve.py

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@
112112
"database", "server", "host", "node", "record", "resource", "id",
113113
"identifier", "number", "key",
114114
})
115+
# Predicate words that make an early, otherwise unknown label plus a number look
116+
# like an entity identity, rather than a mutable value.
117+
_SUBJECT_IDENTITY_VERBS = frozenset({
118+
"has", "have", "contains", "includes", "stores", "owns", "reports",
119+
"serves", "handles", "tracks", "records", "shows", "uses",
120+
})
115121
_ENV_QUALIFIERS = frozenset({
116122
"staging", "production", "prod", "development", "dev", "test", "testing",
117123
"qa", "uat", "preview", "sandbox", "demo", "local",
@@ -584,8 +590,9 @@ def _has_subject_identifier_drift(candidate_text: str, record_text: str) -> bool
584590
``Customer account 100`` -> ``Customer account 200`` has the same shape as
585591
a mutable numeric correction, but ``account`` identifies which customer is
586592
being described. Require the identifier label to occur immediately before
587-
the changed numeric span on both sides, keeping ordinary values such as
588-
``account balance 100`` on the correction path.
593+
the changed numeric span on both sides. For an unknown label, an early
594+
numeric span is accepted only when a subject predicate follows it, keeping
595+
ordinary values such as ``timeout is 30`` on the correction path.
589596
"""
590597
candidate = _surface_tokens(candidate_text)
591598
record = _surface_tokens(record_text)
@@ -600,13 +607,35 @@ def _has_subject_identifier_drift(candidate_text: str, record_text: str) -> bool
600607
continue
601608
if any(_value_kind(value) != "num" for value in [*old_values, *new_values]):
602609
continue
603-
old_label = candidate[old_span[0] - 1][0] if old_span[0] else ""
604-
new_label = record[new_span[0] - 1][0] if new_span[0] else ""
605-
if old_label == new_label and old_label in _SUBJECT_IDENTIFIER_LABELS:
610+
old_label = _subject_identifier_label(candidate, old_span)
611+
new_label = _subject_identifier_label(record, new_span)
612+
if old_label and old_label == new_label:
606613
return True
607614
return False
608615

609616

617+
def _subject_identifier_label(
618+
pairs: list[tuple[str, bool]], span: tuple[int, int]
619+
) -> str:
620+
"""Return the stable label immediately before an identity-like number."""
621+
if not span[0]:
622+
return ""
623+
label = pairs[span[0] - 1][0]
624+
if label in _SUBJECT_IDENTIFIER_LABELS and label not in _ATTRIBUTE_INTRODUCERS:
625+
return label
626+
# A number immediately after the leading noun is an identity convention
627+
# even when the noun is not in our finite label vocabulary. A later
628+
# predicate guard extends this to short subject prefixes without turning
629+
# ordinary values such as "timeout is 30" into identities.
630+
if span[0] == 1 and label not in _ATTRIBUTE_INTRODUCERS and label not in _LIGHT_TOKENS:
631+
return label
632+
if span[0] <= 2 and label not in _ATTRIBUTE_INTRODUCERS and label not in _LIGHT_TOKENS:
633+
following = pairs[span[1]:min(len(pairs), span[1] + 3)]
634+
if any(token in _SUBJECT_IDENTITY_VERBS for token, _ in following):
635+
return label
636+
return ""
637+
638+
610639
def _value_kind(token: str) -> str:
611640
"""Coarse value class so "budget 50k" never swaps against "deadline March 15"."""
612641
if token in _MONTHS or token in _WEEKDAYS:
@@ -693,6 +722,14 @@ def _attr_window(seq: list[tuple[str, bool]],
693722

694723
cand_attr = _attr_window(cand, old_span)
695724
rec_attr = _attr_window(rec, new_span)
725+
# A direct subject label is stronger evidence than a shared attribute
726+
# introducer elsewhere in the prefix. This keeps a changed tenant or
727+
# account identity from being mistaken for a nearby role correction.
728+
if ((old_span[0] and cand[old_span[0] - 1][0] in _SUBJECT_IDENTIFIER_LABELS
729+
and cand[old_span[0] - 1][0] not in _ATTRIBUTE_INTRODUCERS)
730+
or (new_span[0] and rec[new_span[0] - 1][0] in _SUBJECT_IDENTIFIER_LABELS
731+
and rec[new_span[0] - 1][0] not in _ATTRIBUTE_INTRODUCERS)):
732+
return False
696733
if not (cand_attr & rec_attr):
697734
return False
698735
# The window must also carry an attribute introducer on both sides

tests/test_resolve.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,26 @@ def test_numeric_value_after_non_identifier_label_invalidates():
382382
assert res.target_id == "mem_timeout_30"
383383

384384

385+
def test_numeric_subject_identifier_drift_supports_unlisted_entity_labels():
386+
neighbor = _rec(
387+
"Invoice 100 has status paid with archived receipt and audit metadata.",
388+
id="mem_invoice_100",
389+
)
390+
res = resolve(
391+
"Invoice 200 has status paid with archived receipt and audit metadata.",
392+
[(0.9, neighbor)],
393+
)
394+
assert res.op == ResolutionOp.RELATE
395+
assert res.target_id == "mem_invoice_100"
396+
397+
398+
def test_subject_identifier_label_cannot_be_masked_by_shared_attribute_anchor():
399+
neighbor = _rec("User role admin for tenant alpha.", id="mem_tenant_alpha")
400+
res = resolve("User role admin for tenant beta.", [(0.9, neighbor)])
401+
assert res.op != ResolutionOp.INVALIDATE
402+
assert res.target_id == "mem_tenant_alpha"
403+
404+
385405
def test_named_identifier_swap_is_not_proven_a_correction_without_marker():
386406
# ProviderA -> ProviderB beside 4 -> 8 workers could be parallel infrastructure;
387407
# the hashing embedder cannot prove the same predicate, so both stay live.

tools/manual_slider_test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ async function waitForServer(url, timeoutMs = 60000) {
4040
async function startServer() {
4141
log(`Starting dashboard on port ${PORT}...`);
4242
const proc = spawn('python', ['-m', 'scripts.start_dashboard', '--no-open', '--port', String(PORT)], {
43-
cwd: REPO, shell: true,
43+
cwd: REPO, shell: false,
4444
env: {
4545
...process.env,
4646
ENGRAPHIS_DB_PATH: ':memory:',

0 commit comments

Comments
 (0)