Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,21 @@ rather than forcing a source. Two honest advisory flags surfaced for human revie
an effectivity satellite the modeller left without a driving_key, and 4 inferred staging bindings.
This was a verification run (no code change).

Modeler CDK-dedup fix (as of 2026-07-16): re-running the health_insurance demo to refresh the
walkthrough's figures exposed that it FAILED validation 4/4 — every run tripped E_SAT_DUP_ATTR on
the multi-active sat_insured_person_address because the modeler listed the child_dependent_key
(address_type) ALSO among the satellite's attributes, so the generated sat would emit that column
twice (src_cdk + src_payload). The re-model loop couldn't recover within MAX_MODELING_ATTEMPTS. Fix
(both belt-and-braces, since LLM steering alone failed 4/4 even with the error fed back): a [GUIDE]
line in DV_MODELING_RULES telling the modeler a multi-active CDK is a key column, not payload; and a
deterministic rules.attributes_without_cdk(attributes, child_dependent_key) that dv2_modeler applies
in _validate_model — it drops payload attributes normalising to a CDK label (order-preserving,
meaning-preserving: the CDK column still ships via src_cdk), while genuine attr-vs-attr duplicates
stay for E_SAT_DUP_ATTR to flag. After the fix the demo PASSES 3/3 (0 issues); a representative run
is 4 hubs / 3 links / 8 satellites → 15 raw-vault + 8 staging models, matching the walkthrough. 333
tests green (+2: attributes_without_cdk unit + the modeler-dedup integration), ruff clean, mypy
strict clean (32 files). docs/demos/health-insurance-walkthrough.md figures refreshed.

## References
- In-repo methodology notes: docs/methodology/ (DV2.0 rules cheatsheet, IREB mapping, DSAF
mapping, data-contracts approach)
2 changes: 1 addition & 1 deletion docs/demos/health-insurance-walkthrough.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ vault-agent run examples/inputs/health_insurance_requirements.md --out output
requirements: 29
business keys: 6
model: 4 hubs, 3 links, 8 satellites
dbt models: 15 raw vault + a generated stg_* staging layer
dbt models: 15 raw vault + 8 staging
validation: PASSED (0 issues)
```

Expand Down
15 changes: 14 additions & 1 deletion src/vault_agent/agents/dv2_modeler.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

from vault_agent.agents.base import BaseAgent
from vault_agent.grounding import render_schema_prompt_section
from vault_agent.rules.dv2_rules import DV_MODELING_RULES
from vault_agent.rules.dv2_rules import DV_MODELING_RULES, attributes_without_cdk
from vault_agent.state import DVModel, FlagKind, Hub, Link, Satellite, VaultAgentState

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -201,6 +201,19 @@ def _validate_model(self, raw: dict[str, Any], state: VaultAgentState) -> DVMode
asset=sat.name,
)
continue
# A child_dependent_key also listed among the attributes would duplicate a
# satellite column (E_SAT_DUP_ATTR). Drop the redundant payload copy — the CDK
# column is emitted via src_cdk regardless — so a multi-active sat the LLM
# over-populated still validates. Genuine attr-vs-attr dups are left to the gate.
deduped = attributes_without_cdk(sat.attributes, sat.child_dependent_key)
if deduped != sat.attributes:
removed = [a for a in sat.attributes if a not in deduped]
logger.info(
"satellite %r: dropped %d attribute(s) duplicating the "
"child_dependent_key: %s",
sat.name, len(removed), removed,
)
sat.attributes = deduped
kept_satellites.append(sat)

return DVModel(hubs=hubs, links=kept_links, satellites=kept_satellites)
Expand Down
21 changes: 21 additions & 0 deletions src/vault_agent/rules/dv2_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ def effectivity_date_pair(attributes: list[str]) -> tuple[str, str] | None:
"parent's — typical for a multi-active satellite — declare the satellite's "
"source_table (the raw relation feeding it); the parent's business-key column must "
"exist in that relation so the rows attach to the parent",
"A multi-active satellite's child_dependent_key (the sub-sequence key that distinguishes "
"its concurrent rows, e.g. address_type) is a key column, not payload — never also list "
"it among the satellite's attributes, or the generated satellite carries a duplicate "
"column and cannot build",
"When the same business-key value from different sources can mean different objects, add a "
"collision code (source differentiation) rather than silently merging them into one hub",
"When one hub participates twice in a relationship (e.g. a transfer's payer and "
Expand All @@ -109,6 +113,23 @@ def effectivity_date_pair(attributes: list[str]) -> tuple[str, str] | None:
"names a role",
]

def attributes_without_cdk(
attributes: list[str], child_dependent_key: list[str]
) -> list[str]:
"""Drop payload attributes that duplicate a ``child_dependent_key`` column.

A satellite's attributes and its child_dependent_key share ONE column namespace (both
become columns of the generated satellite; the CDK is emitted as ``src_cdk``, the
attributes as ``src_payload``). A multi-active CDK (e.g. ``address_type``) also listed
among the attributes would emit that column twice — the duplicate the warehouse rejects
and ``E_SAT_DUP_ATTR`` blocks. Removing the redundant payload copy is meaning-preserving:
the CDK column stays (via the key), only its duplicate attribute entry goes. Genuine
attribute-vs-attribute duplicates are NOT touched here — the validator still flags those.
Order-preserving; matches by ``normalize_identifier`` so casing/spacing variants collide."""
cdk_norms = {normalize_identifier(key) for key in child_dependent_key}
return [attr for attr in attributes if normalize_identifier(attr) not in cdk_norms]


def _role_prefix(column: str, role: str | None) -> str:
"""Prefix ``column`` with a normalised role; ``role`` None returns it unchanged."""
if role is None:
Expand Down
31 changes: 31 additions & 0 deletions tests/test_agents/test_dv2_modeler.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,37 @@ async def test_satellite_on_link_parent_is_kept() -> None:
assert not result.flags


def test_attributes_without_cdk_drops_only_cdk_collisions() -> None:
from vault_agent.rules.dv2_rules import attributes_without_cdk

# Order preserved; only the CDK-colliding attribute goes (casing/spacing normalised);
# a genuine attr-vs-attr duplicate is left in place for the validator to flag.
assert attributes_without_cdk(
["street", "city", "Address Type", "street"], ["address type"]
) == ["street", "city", "street"]
# No CDK → unchanged.
assert attributes_without_cdk(["a", "b"], []) == ["a", "b"]


async def test_multi_active_cdk_duplicated_in_attributes_is_deduped() -> None:
"""A multi-active satellite whose child_dependent_key also appears in attributes would
trip E_SAT_DUP_ATTR (one column emitted twice); the modeler drops the redundant payload
copy — the CDK column still ships via src_cdk — so the sat validates and builds."""
payload = _valid_payload()
payload["satellites"].append(
{"name": "sat_customer_address", "parent": "hub_customer",
"attributes": ["street", "city", "address type"], "description": "addresses",
"sat_type": "multi_active", "child_dependent_key": ["address type"],
"requirement_ids": []}
)
stub = StubExtractor(payload)
result = await Dv2ModelerAgent(extractor=stub).run(_state())

sat = next(s for s in result.dv_model.satellites if s.name == "sat_customer_address")
assert sat.child_dependent_key == ["address type"] # the CDK stays
assert sat.attributes == ["street", "city"] # its duplicate payload copy is gone


async def test_invalid_hub_is_skipped_and_logged() -> None:
payload = _valid_payload()
payload["hubs"].append({"name": "hub_broken"}) # missing required fields
Expand Down
Loading