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+
610639def _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
0 commit comments