Skip to content

Commit d102cb7

Browse files
jirhikerclaude
andcommitted
fix(ingestion): do not match on external ids by default
Reconciling against staging answered 3.2: all 38 Diver-HUB points match Ocotillo wells by name, nothing ambiguous, nothing unmatched. The wells already exist, so the seeding half creates none. The same data showed external-id matching is unsafe here. thing_id_link holds 11,148 links from nine organization/relation pairs that disagree with each other. SO-0131 carries NMBGMR "BRN-E04B (shallow)" plus an unattributed "BRN-E04A", while SO-0132 carries NMBGMR "BRN-E04A (deep)" plus an unattributed "BRN-E04B" -- the two sources swap which physical well is A and which is B. Matching BRN-E04A against that returns one confident hit on SO-0131, contradicting NMBGMR, because the parenthetical suffix stops the collision registering as ambiguous. That is worse than the ambiguity the module was built to escalate: a wrong answer delivered with no sign of trouble. So the fallback is opt-in, and a test pins those exact rows. It costs nothing today, since every point matches by name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 50963f4 commit d102cb7

3 files changed

Lines changed: 75 additions & 11 deletions

File tree

automated_ingestion/sources/san_acacia/reconcile.py

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -125,16 +125,39 @@ def _normalize(value: str) -> str:
125125
return "".join(c for c in (value or "") if c.isalnum()).upper()
126126

127127

128-
def match_point(point: VendorPoint, candidates: Iterable[ThingCandidate]) -> Match:
129-
"""Decide one point against the wells it might be."""
128+
def match_point(
129+
point: VendorPoint,
130+
candidates: Iterable[ThingCandidate],
131+
use_external_ids: bool = False,
132+
) -> Match:
133+
"""Decide one point against the wells it might be.
134+
135+
``use_external_ids`` is off by default, for a specific reason.
136+
``thing_id_link`` holds identifiers from several organizations that disagree
137+
with each other. In staging, ``SO-0131`` carries NMBGMR ``BRN-E04B
138+
(shallow)`` plus an unattributed ``BRN-E04A``, while ``SO-0132`` carries
139+
NMBGMR ``BRN-E04A (deep)`` plus an unattributed ``BRN-E04B`` -- the two
140+
sources swap which physical well is A and which is B.
141+
142+
Matching ``BRN-E04A`` against that returns a single confident hit on
143+
SO-0131, contradicting NMBGMR, because the parenthetical suffix stops the
144+
collision registering as ambiguous. A wrong answer delivered confidently is
145+
worse than no answer.
146+
147+
It costs nothing today: all 38 Diver-HUB points match Ocotillo wells by name.
148+
"""
130149
target = _normalize(point.name)
131150

132151
by_name = [c for c in candidates if _normalize(c.name) == target]
133-
by_external = [
134-
c
135-
for c in candidates
136-
if any(_normalize(x) == target for x in c.external_ids) and c not in by_name
137-
]
152+
by_external = (
153+
[
154+
c
155+
for c in candidates
156+
if any(_normalize(x) == target for x in c.external_ids) and c not in by_name
157+
]
158+
if use_external_ids
159+
else []
160+
)
138161

139162
# Name first: it is the identifier the Bureau uses, and an external id link
140163
# is a record of an association someone made, which may be older.
@@ -153,13 +176,17 @@ def match_point(point: VendorPoint, candidates: Iterable[ThingCandidate]) -> Mat
153176

154177

155178
def reconcile(
156-
points: Iterable[VendorPoint], candidates: Iterable[ThingCandidate]
179+
points: Iterable[VendorPoint],
180+
candidates: Iterable[ThingCandidate],
181+
use_external_ids: bool = False,
157182
) -> ReconciliationReport:
158183
"""Match every vendor point, reporting rather than resolving."""
159184
candidate_list = list(candidates)
160185
report = ReconciliationReport()
161186
for point in points:
162-
report.matches.append(match_point(point, candidate_list))
187+
report.matches.append(
188+
match_point(point, candidate_list, use_external_ids=use_external_ids)
189+
)
163190
return report
164191

165192

automated_ingestion/tests/test_reconcile.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,16 +52,47 @@ def test_adjacent_identifier_is_not_a_match():
5252
assert match.kind is MatchKind.UNMATCHED
5353

5454

55-
def test_external_id_match_when_the_name_differs():
55+
def test_external_ids_are_ignored_by_default():
5656
match = match_point(
5757
POINT,
5858
[ThingCandidate(thing_id=9, name="Renamed Well", external_ids=("SO-0125",))],
5959
)
60+
assert match.kind is MatchKind.UNMATCHED
61+
62+
63+
def test_external_id_match_when_explicitly_enabled():
64+
match = match_point(
65+
POINT,
66+
[ThingCandidate(thing_id=9, name="Renamed Well", external_ids=("SO-0125",))],
67+
use_external_ids=True,
68+
)
6069
assert match.kind is MatchKind.EXTERNAL_ID
6170
assert match.thing_id == 9
6271

6372

73+
def test_external_ids_can_produce_a_confident_wrong_answer():
74+
"""Why external id matching is off by default. Real rows from staging.
75+
76+
SO-0131 and SO-0132 swap which physical well is A and which is B between
77+
NMBGMR and the unattributed source. Matching BRN-E04A returns SO-0131 with
78+
no hint of trouble, while NMBGMR asserts SO-0132 is BRN-E04A -- the
79+
parenthetical suffix stops the collision registering as ambiguous.
80+
"""
81+
candidates = [
82+
ThingCandidate(2369, "SO-0131", ("BRN-E04B (shallow)", "BRN-E04A")),
83+
ThingCandidate(2373, "SO-0132", ("BRN-E04A (deep)", "BRN-E04B")),
84+
]
85+
enabled = match_point(
86+
VendorPoint(999, "BRN-E04A"), candidates, use_external_ids=True
87+
)
88+
assert enabled.thing_id == 2369 # contradicts NMBGMR, and looks certain
89+
90+
default = match_point(VendorPoint(999, "BRN-E04A"), candidates)
91+
assert default.kind is MatchKind.UNMATCHED # escalates instead
92+
93+
6494
def test_name_wins_over_external_id():
95+
# Only relevant when external ids are enabled.
6596
# The name is the identifier the Bureau uses now; a link records an
6697
# association someone made earlier, which may be stale.
6798
match = match_point(
@@ -70,6 +101,7 @@ def test_name_wins_over_external_id():
70101
ThingCandidate(thing_id=7, name="SO-0125"),
71102
ThingCandidate(thing_id=9, name="Other", external_ids=("SO-0125",)),
72103
],
104+
use_external_ids=True,
73105
)
74106
assert match.thing_id == 7
75107

docs/automated-ingestion-pipeline-plan.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,12 @@ Reconciliation report built — `sources/san_acacia/reconcile.py` and `scripts/r
277277
-**Never picks a winner.** More than one candidate is `ambiguous` and escalates; none is `unmatched` and escalates. Ingestion does not create wells, and choosing between two plausible ones is the judgement that must not be automated.
278278
-`report.ready` is false unless *every* point resolved, and false for empty input. A partial load produces a series that looks complete and is not.
279279
- ✅ The script exits non-zero when anything needs a human, so it can gate a later step without relying on someone reading the output.
280-
- ⬜ Run it against staging and production and act on the result.
280+
-**Run against staging: all 38 points match by name. Nothing ambiguous, nothing unmatched, `ready = True`.** The wells already exist — SO-0125 is thing 2343, SO-0131 is 2369, and so on through 277 `SO-` wells in that database. So the seeding half creates no wells; it only needs the parameter, sensor, deployments and external identifiers.
281+
- ⬜ Run against production and confirm the same.
282+
283+
**External-id matching is off by default, on evidence.** `thing_id_link` in staging holds 11,148 links from nine organization/relation pairs — NMBGMR (8,603), PLSS (7,052), an unattributed "Unknown" (4,825), NMOSE, USGS, NMED, TWDB — and they disagree with each other. `SO-0131` carries NMBGMR `BRN-E04B (shallow)` plus an unattributed `BRN-E04A`, while `SO-0132` carries NMBGMR `BRN-E04A (deep)` plus an unattributed `BRN-E04B`: the two sources swap which physical well is A and which is B. (`SO-0262`/`SO-0263` disagree more sharply still — NMBGMR calls them NRCS 3A/3B, the other source NRCS 2.)
284+
285+
Matching `BRN-E04A` against that returns a single confident hit on `SO-0131`, contradicting NMBGMR, because the parenthetical suffix stops the collision registering as ambiguous. A wrong answer delivered confidently is worse than no answer, so the fallback is opt-in and a test pins the real rows.
281286
- ⬜ The seeding half: data migration creating missing `Location`/`Thing`, lexicon terms, DTW `Parameter`, `VanEssenDiver` `Sensor`, one `Deployment` per well, the vendor `uid` as external identifier, and `DataProvenance` for Van Essen-sourced attributes.
282287

283288
### 3.3 — Represent "public but provisional"

0 commit comments

Comments
 (0)