<USER_REQUEST>
Four things the existing system can't do today — naming them before designing around them

No edge connects application code to Terraform. A Python function calling boto3.client('s3').put_object(...) and the aws_s3_bucket resource it writes to have zero graph relationship — Terraform and application-code IR were built and graphed entirely separately (Phase 2.1–2.6). "Pull in relevant Terraform resources" can't be real graph traversal; at best it's a same-repository heuristic, and I'll design it as a clearly separate, lower-confidence mechanism — not conflated with the CALLS/IMPORTS traversal.
Kubernetes, CI/CD, OpenAPI, generic config files have no parser, no IR, no graph node — at all. This is the same Group-B gap named in the original M2 architecture audit. Phase 14 cannot reach them via traversal because there's nothing to traverse to. The only honest option is a raw-file-attachment fallback (read the text, no understanding) or excluding them outright until those parsers exist. I'm scoping them out of this phase rather than pretending a heuristic glob is "infrastructure support."
No EvidenceStore exists. Phase 11.1 returns a plain list[Evidence]. I'll add a minimal indexed EvidenceStore (same shape as FactStore/SiteStore) as a small, explicitly-flagged addition to Phase 11.1 — not new Phase 14 scope creep, just closing a gap your own input list correctly assumed was already there.
SiteStore is missing from your 5 listed inputs but is structurally required. Evidence.site_id only resolves to a site_kind/source_node via SiteStore. I'm adding it as a 6th input.

Package structure — your list, plus one justified addition
src/meridian/clustering/
├── models.py
├── cluster_builder.py
├── graph_traversal.py
├── cluster_ranker.py
├── cluster_pruner.py
└── token_budget.py

src/meridian/prompting/          🆕 — see "Prompt Context Builder" below for why this isn't nested in clustering/
├── models.py
└── builder.py
1. Cluster models
pythonclass ClusterNodeKind(str, Enum):
    FUNCTION     = "function"
    CLASS        = "class"
    MODULE       = "module"
    RESOURCE     = "resource"       # Terraform — real graph node
    DATA_SOURCE  = "data_source"    # Terraform — real graph node
    RAW_FILE     = "raw_file"       # heuristic-attached, unparsed — explicitly second-class

@dataclass(frozen=True, slots=True)
class ClusterNode:
    node_id:           str               # = RepositoryGraph node id (qualified_name) for real nodes;
                                          #   synthetic id for RAW_FILE
    kind:              ClusterNodeKind
    rel_path:          str
    relevance_score:   float
    hop_distance:      int               # 0 = anchor; see graph_traversal below
    inclusion:         str                # "full" | "summarized" | "dropped" — set by cluster_pruner
    source_text:       str | None        # populated only when inclusion == "full"
    summary_text:      str | None        # populated only when inclusion == "summarized" — deterministic, see below
    fact_ids:          tuple[str, ...]    # facts attributed to this node, if any
    estimated_tokens:  int

@dataclass(frozen=True, slots=True)
class ClusterEdge:
    source_id:   str
    target_id:   str
    edge_type:   str   # mirrors EdgeType.value for real edges; "heuristic_infra_link" for the
                        # Terraform-linking case — never silently indistinguishable from a real edge

@dataclass(frozen=True, slots=True)
class ClusterMetadata:
    cluster_id:               str
    finding_id:               str
    anchor_node_ids:          tuple[str, ...]
    max_hops:                 int
    max_fanout_cap:           int       # separate safety bound — see graph_traversal below
    total_nodes_considered:   int       # before pruning
    total_nodes_included:     int       # after pruning (full + summarized)
    estimated_tokens:         int
    token_budget:             int
    truncated:                bool      # True if pruning had to drop or summarize anything

@dataclass(frozen=True, slots=True)
class ClusterSummary:
    """Deterministic, templated — NOT LLM-generated. Phase 14 stays inside
    M3's no-generation boundary; only Phase 15 touches an LLM."""
    cluster_id:         str
    text:                str
    node_kind_counts:   dict[str, int]
    rel_paths:           tuple[str, ...]

@dataclass(frozen=True, slots=True)
class ContextCluster:
    cluster_id:   str    # stable hash of finding_id — one cluster identity per finding, same
                          # stability convention as fact_id/claim_id/evidence_id/finding_id
    finding_id:   str
    nodes:        tuple[ClusterNode, ...]
    edges:        tuple[ClusterEdge, ...]
    summary:      ClusterSummary
    metadata:      ClusterMetadata
2. Graph traversal strategy — answering your three questions directly
Anchors (the hop-0 set): resolve Finding.evidence_ids → EvidenceStore → for each Evidence, resolve site_id (via SiteStore, if not None) and every supporting_fact_ids (via FactStore). The anchor set is the union of every resulting source_node — these are already valid RepositoryGraph node ids, no translation needed (same qualified_name convention everywhere in this codebase).
How many hops: default max_hops=2, configurable — same documented-heuristic pattern as rbac_detector.py's MAX_HOPS=3. Hop 0 = the anchor itself. Hop 1 = direct structural context (containing class/module via CONTAINS, direct callers/callees via CALLS, direct IMPORTS) — almost always essential. Hop 2 = "the caller of my caller" — often still relevant for why a finding matters, rarely beyond that.
How it stops: two independent bounds, not one — max_hops alone doesn't protect against fan-out (a function called from 50 places still produces 50 hop-1 nodes). A separate max_fanout_cap (default 200 total nodes visited) hard-stops the BFS regardless of hop count, so one "god function" anchor can't blow up traversal time before pruning even runs.
Terraform linking: same-repository heuristic only (per constraint #1 above) — e.g. a DATABASE_WRITE_SITE finding may pull in aws_db_instance/aws_dynamodb_table resources elsewhere in the repo, tagged with edge_type="heuristic_infra_link" so it's never confused with a real resolved edge downstream.
3. Relevance scoring — named as a heuristic, not derived
pythondef score(hop_distance, has_direct_evidence, pagerank_score, node_kind) -> float:
    base            = 1.0 / (1 + hop_distance)
    evidence_boost  = 2.0 if has_direct_evidence else 0.0     # the actual thing being judged
    pagerank_term   = pagerank_score * 0.5                     # modest, never dominant
    kind_bonus      = {"function": 0.3, "class": 0.2, "resource": 0.3}.get(node_kind, 0.0)
    return base + evidence_boost + pagerank_term + kind_bonus
Reuses graph.pagerank() (already built, Phase 2.6) as the "structural centrality" signal. This is a tunable combination, not a scientifically derived formula — same honesty standard as the fan-in threshold in data_minimization_detector.py.
4. Token budgeting
python@dataclass(frozen=True)
class TokenBudgetConfig:
    model_context_window:          int     # 8000 / 16000 / 32000 / 128000 — caller-supplied per selected model
    reserved_for_prompt_overhead:  int = 1500   # system preamble + finding metadata
    reserved_for_response:         int = 1500   # headroom for the LLM's own output

    @property
    def usable_for_cluster_content(self) -> int:
        return max(0, self.model_context_window - self.reserved_for_prompt_overhead - self.reserved_for_response)
Token estimation reuses the simple len(text) / 3.5 * 1.2 heuristic already proven in the legacy rate_limit.py — no new tokenizer dependency needed for budgeting purposes.
5. Cluster pruning — and the deterministic-summary rule
Sort by relevance_score descending → greedily include source_text in full until the budget would be exceeded → for nodes below that line but above a minimum relevance floor, generate summary_text instead → everything else is dropped, counted in ClusterMetadata.
summary_text is templated, never an LLM call — e.g. "function save() in User (auth.py:42-58) — 16 lines, calls: kms.encrypt, db.commit". This is the load-bearing decision in this whole phase: Phase 14 is still upstream of any LLM, and summarizing via inference here would quietly smuggle a non-deterministic step into what's supposed to be the last deterministic stage before Phase 15.
6. Prompt Context Builder — why it's not nested in clustering/
You're right that this needs to outlive clustering as a concept — Chat and MCP will build a PromptContext from something that isn't a ContextCluster at all. Putting it in a sibling prompting/ package keeps that reuse honest instead of implying it's clustering-specific.
python@dataclass(frozen=True, slots=True)
class PromptSection:
    title:          str
    content:        str
    section_kind:   str   # "finding_summary" | "code_context" | "evidence_chain" | "infra_context"

@dataclass(frozen=True, slots=True)
class PromptContext:
    context_id:           str
    source_cluster_id:    str | None     # None for non-clustering callers (chat, MCP)
    system_preamble:       str
    structured_sections:    tuple[PromptSection, ...]
    estimated_tokens:       int
Evaluator/Critic (Phase 15/16) render this into provider-specific message format — they never touch ContextCluster internals directly.
7. Multi-language support
Already satisfied by inheritance — RepositoryGraph nodes/edges are language-agnostic by construction (same FUNCTION/CLASS/MODULE shape across all 8 source languages since Phase 2.1–2.6). No new per-language code needed in clustering/.
8. Infrastructure artifacts — scoped per constraint #1/#2 above
Terraform: real, via the heuristic same-repo link. Kubernetes/CI-CD/OpenAPI/generic config: excluded from this phase, named explicitly rather than silently attempted via a glob that would imply more capability than exists.
9 & 10. Tests and provider integration
Deterministic tests against real Flask + real meridian.db, same discipline as every prior phase — locking in real node counts/token estimates for a real Finding, not synthetic numbers. clustering/ and prompting/ import nothing from litellm/AsyncRateLimiter/LLMConfig — that integration is entirely Phase 15's responsibility, consuming PromptContext as a plain value object.

Explicit approval needed on 5 points before I write anything

The two scope corrections (no Terraform↔code edge exists; K8s/CI-CD/OpenAPI/config excluded this phase) — accept, or do you want a crude raw-file fallback for the latter despite the honesty cost?
EvidenceStore added to Phase 11.1, SiteStore added as a 6th clustering input — approve?
max_hops=2 default, max_fanout_cap=200 — adjust either?
The relevance scoring formula — approve the heuristic, or want different weights?
prompting/ as a sibling package, not nested in clustering/ — confirm the structural deviation from your literal file list?
You said: Don't exclude Kubernetes/OpenAPI/CI-CD entirelyDon't exclude Kubernetes/OpenAPI/CI-CD entirely
This is the biggest thing I'd push back on.
Claude says:
Exclude Kubernetes, CI/CD, OpenAPI, config files until parsers exist. Pasted text.txt
I disagree.
Meridian's value proposition is enterprise compliance. Those artifacts are too important to ignore.
Instead introduce a second-class node:ArtifactNode

parsed = False

confidence = LOW

type =
    Kubernetes
    GitHub Actions
    GitLab CI
    Dockerfile
    OpenAPI
    YAML
    JSONThey won't participate in graph traversal, but they can be attached to a cluster when relevant.
For example:Authentication Cluster

↓

auth.py
jwt.py
permissions.py

+

.github/workflows/deploy.yml

+

k8s/deployment.yamlThat gives the LLM much better context than omitting them.
2. Keep PromptContext separate
I completely agree.
This is one of the strongest design decisions.clustering/

↓

ContextCluster

↓

prompting/

↓

PromptContext

↓

EvaluatorLater MCP, chat, AI Governance and Reporting can all reuse PromptContext. Pasted text.txt
3. Token Budget should know the model
Instead of:model_context_windowI'd have:ProviderContext

provider

model

context_window

supports_reasoning

supports_tool_calling

supports_cacheWhy?
Because Meridian already lets the user choose:ClaudeGPTGeminiOllamaOpenRouter 
Different models deserve different budgets and prompt styles.Cluster scoring
Current score:hop
+
evidence
+
pagerank
+
kindis a good starting point. Pasted text.txt
I'd add:detector confidence

fact confidence

capability importance

control severityFor example:Encryption

↓

Critical RBI control

↓

Should outrank

↓

Debug loggingCluster Builder
I'd add one more module:cluster_explainer.pyIt generates deterministic explanations like:This cluster was built because:

Finding

↓

Evidence

↓

Authentication Site

↓

CALLS

↓

jwt.py

↓

middleware.py

↓

Terraform IAM resourceThat makes debugging much easier.
6. The biggest improvement
I don't want PromptContext to contain only code.
I want it to contain structured compliance knowledge.
Something like:Finding

Capability

Objective

Control

Evidence

Repository Summary

Code Context

Infrastructure ContextThe evaluator then receives one structured object instead of manually assembling prompts.
7. Don't hardcode Terraform heuristics
Claude proposes heuristic links. Pasted text.txt
That's fine initially, but design an interface:InfrastructureLinker

↓

TerraformLinker

↓

KubernetesLinker

↓

OpenAPILinker

↓

DockerLinkerWhen those parsers arrive later, you replace the implementation instead of changing the clustering system.
My approval
I'd tell Claude:
Approved with a few adjustments:Do not exclude Kubernetes, OpenAPI, CI/CD, Dockerfile, or configuration assets completely. Introduce low-confidence ArtifactNodes that can be attached to clusters without graph traversal.Keep prompting/ separate from clustering/.Make token budgeting provider/model-aware using Meridian's existing model configuration.Extend cluster relevance scoring with detector confidence, fact confidence, capability importance, and control severity.Add a deterministic cluster_explainer.py for auditability.Design PromptContext as structured compliance context (finding, capability, control, evidence, repository summary, code, infrastructure), not just concatenated source code.Abstract infrastructure linking behind an interface so future parsers plug in without changing Phase 14.
keep this iin mind and lets start codingShow more2:47 PMClaude responded: All 7 amendments are real improvements — especially the node-score vs.Architected seven amendments into comprehensive clustering system with artifact nodes, provider-aware budgeting, and structured compliance contextsArchitected seven amendments into comprehensive clustering system with artifact nodes, provider-aware budgeting, and structured compliance contextsAll 7 amendments are real improvements — especially the node-score vs. cluster-priority distinction I need to flag as a correction to your scoring request. Let me build this properly, starting with the small Phase 11.1 gap-fill, then working up through the full clustering and prompting packages with real verification at each stage.Check current evidence/models.py before adding EvidenceStoreCheck current evidence/models.py before adding EvidenceStoreAdding EvidenceStore — the small Phase 11.1 gap your input list correctly assumed was already there:Add EvidenceStore - small gap-fill closing what Phase 14's input list assumed already existedAdd EvidenceStore - small gap-fill closing what Phase 14's input list assumed already existedQuick regression check before moving on:Quick smoke test of EvidenceStore and full regression checkQuick smoke test of EvidenceStore and full regression checkNo regressions. Now let's build the clustering/ package, starting with the models:Architecting artifact discovery and linker infrastructure modulesArchitecting artifact discovery and linker infrastructure modulesNow token_budget.py with ProviderContext (amendment 3):Debugging operator precedence logic in directory filteringDebugging operator precedence logic in directory filteringNow the infrastructure linking package — starting with artifact discovery (bypassing discovery.py's filter, since these extensions are explicitly unsupported there):Architecting infrastructure linkers with pluggable parser interfacesArchitecting infrastructure linkers with pluggable parser interfacesLet me fix a fragile operator-precedence line in the directory filter — it works by accident, not by clear intent:Make directory filter logic explicit with parens instead of relying on operator precedenceMake directory filter logic explicit with parens instead of relying on operator precedenceLet's verify this against a real synthetic repo with all the artifact types before continuing:Verify artifact discovery against a real synthetic repo with all artifact types plus noise dirsVerify artifact discovery against a real synthetic repo with all artifact types plus noise dirsAll 6 real artifact types correctly classified, .git and node_modules correctly excluded. Now the InfrastructureLinker interface:Clarifying TerraformLinker's architecture and artifact tracking mechanismsClarifying TerraformLinker's architecture and artifact tracking mechanismsLet me fix the interface signature — TerraformLinker needs FactStore access to reuse Phase 9.1's already-extracted TERRAFORM_RESOURCE facts (which already carry capability_hints), rather than duplicating a keyword table:Architecting heuristic linkers for infrastructure and CI/CD systemsArchitecting heuristic linkers for infrastructure and CI/CD systemsNow terraform_linker.py — real graph/Fact-backed:Architected heuristic linkers for infrastructure artifacts using keyword extractionArchitected heuristic linkers for infrastructure artifacts using keyword extractionNow the heuristic linkers — Kubernetes/GitHub Actions/GitLab CI/Docker/OpenAPI, sharing one keyword-matching base per your interface ask:Validating keyword overlap between artifacts and authentication clusterValidating keyword overlap between artifacts and authentication clusterLet's verify the heuristic linkers work against a realistic case before moving on:Architecting bidirectional graph traversal for comprehensive caller-callee mappingArchitecting bidirectional graph traversal for comprehensive caller-callee mappingPrecise — the relevant workflow matched, the unrelated K8s manifest correctly didn't. Now graph_traversal.py — the BFS strategy with the dual hop/fanout bound:Validating BFS traversal logic across authentication module hopsValidating BFS traversal logic across authentication module hopsLet's verify this BFS against real Flask before continuing:Validating cluster scoring against real encryption findingsValidating cluster scoring against real encryption findingsExcellent, real and sensible context. Now cluster_ranker.py — both scoring functions, correctly separated:Verified cluster scoring logic against real encryption and logging findingsVerified cluster scoring logic against real encryption and logging findingsLet's verify the priority ranking direction against real data — does a Critical-severity finding actually outrank a Low-severity one?Validated scoring logic and commenced building token-budget-aware cluster prunerValidated scoring logic and commenced building token-budget-aware cluster prunerConfirmed — Critical outranks Low by ~2x, exactly the example given. Now cluster_pruner.py:Prepared to validate pruner logic with synthetic test casesPrepared to validate pruner logic with synthetic test casesLet's verify the pruner logic with a quick synthetic check before building the explainer:Orchestrating cluster assembly through evidence resolution and graph traversalOrchestrating cluster assembly through evidence resolution and graph traversalExactly right — full/summarized/dropped tiers all behaving correctly. Now cluster_explainer.py (amendment 5):Architecting cluster_builder.py and reconciling Terraform node promotion strategyArchitecting cluster_builder.py and reconciling Terraform node promotion strategyNow cluster_builder.py — the orchestrator tying everything together:Verifying parsing logic recovers start_line correctlyVerifying parsing logic recovers start_line correctlyLet me confirm IRLocation.__str__'s exact format before testing — my parsing assumption needs verification:Verified location attribute parsing logic across all node typesVerified location attribute parsing logic across all node typesConfirmed — location attribute is only set for CLASS/FUNCTION nodes, not MODULE/RESOURCE, but my fallback handles that correctly since rel_path is always the first ::-segment by construction across every node-ID convention in this codebase. Let's run the real verification now:Validating false positives in workflow configuration matchingValidating false positives in workflow configuration matchingA real precision problem — .github/workflows/lock.yaml matched on the word "examples" (a generic top-level directory name), and tests.yaml matched on "tests". These are too weak to be meaningful. Let me investigate and fix properly:Reconciling artifact matching logic with file context verificationReconciling artifact matching logic with file context verificationConfirmed — both are real false positives from generic top-level directory names. Fixing with two changes: only tokenize path segments closest to the actual file, plus a stopword list for generic noise terms:Fix keyword extraction precision - only use specific path segments near the file, exclude generic noise termsFix keyword extraction precision - only use specific path segments near the file, exclude generic noise termsNow let's re-verify against real Flask:Verifying Flask's structural validity within the codebaseVerifying Flask's structural validity within the codebaseLet's verify this remaining match is genuinely meaningful, not another coincidence:Debugging test fixture for git directory exclusionDebugging test fixture for git directory exclusionConfirmed genuinely meaningful — publish.yaml line 51 references pypi.org/project/Flask, the actual PyPI publish workflow for the Flask package itself, and the cluster legitimately includes src/flask/__init__.py. Real signal, not noise. Now let's write the comprehensive test suite for clustering/, then build prompting/.Quick regression check before writing the test suiteQuick regression check before writing the test suiteNo regressions. Now the test suite for clustering/:Created 3 filesCreated 3 filesNow tests for the linkers, including the regression guard for the precision bug just fixed:Tests for infrastructure linkers including regression guard for the keyword-precision fixTests for infrastructure linkers including regression guard for the keyword-precision fixNow tests for graph_traversal.py, cluster_ranker.py, cluster_pruner.py, and cluster_explainer.py:Created 3 filesCreated 3 filesNow the end-to-end test against real Flask, and the explainer test:Tests for cluster explainerTests for cluster explainerNow let's run the suite so far before writing the cluster_builder e2e tests:Unearthing the actual bug in artifact discovery logicUnearthing the actual bug in artifact discovery logicTwo failures — let me debug each. First the .git exclusion issue:Scrutinizing test fixture logic and `.git` directory traversal assumptionsScrutinizing test fixture logic and `.git` directory traversal assumptionsThe filter logic works correctly in isolation — let me check if the actual file has the fix applied correctly:Identified substring matching bug in test assertion logicIdentified substring matching bug in test assertion logicFound it — and it's a delicious irony. My test assertion ".git" in p is naive substring matching, and ".github/workflows/deploy.yml" legitimately contains .git as a substring (.git + hub) — the exact same class of bug Phase 9.1 found and fixed multiple times, this time in my own test, not the implementation:Fix test's naive substring check - .github legitimately contains .git as a substringFix test's naive substring check - .github legitimately contains .git as a substringNow the bytes/str concat bug in the fanout test:Fix str/bytes concat bug in test fixtureFix str/bytes concat bug in test fixture2 / 2You said: ContinueContinue3:53 PMClaude responded: Both fixed.Re-run the two previously failing test files after fixesRe-run the two previously failing test files after fixesBoth fixed. Now the cluster_builder end-to-end test against real Flask, completing Phase 14's clustering/ package:Validated tests and charted next development phaseValidated tests and charted next development phaseAll 7 pass on real Flask data. Let's check the full suite for regressions, then build prompting/:Architecting evidence resolution and context conversion mechanismsArchitecting evidence resolution and context conversion mechanismsNow prompting/ — PromptContext as structured compliance knowledge (amendment 6):Validating structured output against amendment framework specificationsValidating structured output against amendment framework specificationsNow builder.py — ContextCluster + Finding + resolver → PromptContext:PromptContext builder - converts a ContextCluster + Finding into structured compliance knowledgePromptContext builder - converts a ContextCluster + Finding into structured compliance knowledgeLet's run the full real-Flask pipeline through prompting/ now:Architected structured compliance framework with comprehensive control validationArchitected structured compliance framework with comprehensive control validationThis is exactly the structured compliance knowledge the amendment called for — real control title, real audit question, real sibling-capability context showing what else is missing. Now the test suite for prompting/:Tests for PromptContext modelsTests for PromptContext modelsNow the end-to-end test against real Flask, locking in the exact verified output:End-to-end PromptContext builder test against real Flask, locking in the structured output verified manuallyEnd-to-end PromptContext builder test against real Flask, locking in the structured output verified manuallyNow let's run the complete test suite:Tallied test results and prepared final presentationTallied test results and prepared final presentation461 passed, 1 skipped. Let's get the final file count and present everything.Ran 2 commandsRan 2 commands28 files. Presenting in batches:Read 2 filesRead 2 filesNow all 11 test files:Presented 11 filesPresented 11 filesPhase 14 Complete — 28 files, 461 tests passing (1 honestly skipped), 176 files total
All 7 amendments implemented, with one real correction surfaced while building, not assumed
#AmendmentWhat got built1K8s/CI-CD/OpenAPI/Docker not excludedArtifactNode + 6 working linkers, real keyword matching — not stubs2prompting/ separate from clustering/Sibling package, zero dependency on ContextCluster internals3Provider/model-aware budgetingProviderContext replaces bare int, curated table + safe fallback chain4Extended scoringCorrected, not just added — see below5cluster_explainer.pyReconstructs real BFS chains via parent_node_id, not flat hop buckets6Structured PromptContext8 sub-objects: Finding/Capability/Objective/Control/Evidence/RepoSummary/Code/Infra7InfrastructureLinker interfaceOne ABC, TerraformLinker (real) + _HeuristicTextLinker base (6 named subclasses)
The correction on #4: control severity and capability importance are constant across every node in one cluster (one cluster = one finding = one control = one severity) — they cannot rank nodes within a cluster against each other, only rank clusters against each other. Split into score_node() (hop/evidence/pagerank/kind/fact-confidence/detector-confidence) and score_cluster_priority() (severity/importance/finding-confidence). Verified against real data: Critical-severity finding scores 1.670 vs. Low-severity's 0.830 — exactly the "Encryption should outrank debug logging" example, just operating at the layer where it can actually discriminate.
Two real bugs found by testing against real Flask, not assumed correct

False-positive artifact matches: .github/workflows/lock.yaml matched on "examples" (a generic top-level directory name appearing in an unrelated GitHub-issue-management comment), tests.yaml matched its own filename. Fixed by only tokenizing path segments nearest the actual file plus a stopword list. The one remaining match after the fix — publish.yaml ↔ src/flask/__init__.py — was verified genuinely meaningful: line 51 of that workflow literally references pypi.org/project/Flask.
My own test had the identical bug class: assert ".git" not in p failed because .github legitimately contains .git as a substring — the exact false-positive-substring pattern Phase 9.1 hit three times, this time in my test code, not the implementation. Fixed with exact path-component comparison.

Real, worked example — DPDP_SCH_001 on Flask, full pipeline
21 nodes traversed (login()'s blog.py siblings + db.py + cross-module imports)
3 real anchors: blog.py::create/update/delete (@login_required)
1 infrastructure artifact: publish.yaml (genuinely meaningful, verified by reading the file)
5,026 tokens — fits Sonnet's window with zero pruning needed
priority_score = 1.670 (Critical severity)

PromptContext:
  control_title:    "Penalty for Breach of Security Safeguards (Section 8(5))"
  framework_name:   "Digital Personal Data Protection Act, 2023"
  audit_question:   verbatim from meridian.db
  3 evidence records, each pointing to a real file:line
  5 related sibling capabilities shown as NOT_DETECTED (Encryption_At_Rest,
    Data_Classification, ...) — real signal for "is this control ACTUALLY satisfied"
  21 code sections + 1 infrastructure section
Milestone 4
  Phase 13  ██████████ 100%
  Phase 14  ██████████ 100%
  Phase 15  ░░░░░░░░░░   0%  LLM Evaluator
  Phase 16  ░░░░░░░░░░   0%  LLM Critic
  Phase 17  ░░░░░░░░░░   0%  Provenance Merge

Meridian clustering init · PY

"""Phase 14 — Context Clustering.

 

Finding + EvidenceStore + RepositoryGraph + FactStore + SiteStore +

RepositoryInventory -> ContextCluster: a token-budgeted, relevance-

ranked bundle of code/infrastructure context an LLM can actually

reason about, instead of an entire repository.

 

One correction to the approved scoring design, surfaced while building

it rather than silently absorbed: control severity and capability

importance are CONSTANT across every node in one cluster (a cluster

belongs to exactly one Finding, hence one control, hence one severity)

— they cannot discriminate which nodes within a cluster matter more

than others, which is what node-level relevance scoring is for. The

"Encryption / Critical RBI control should outrank Debug logging"

example is real, but it's a statement about which CLUSTER gets

attention first when many findings are queued, not about which NODE

within one cluster survives pruning. So this phase computes two

separate scores:

    - node relevance (hop distance, direct evidence, pagerank, kind,

      fact confidence, detector confidence) — decides what survives

      pruning WITHIN a cluster. See cluster_ranker.score_node().

    - cluster priority (control severity, capability importance,

      finding confidence) — decides which cluster matters more

      relative to OTHER clusters/findings. See

      cluster_ranker.score_cluster_priority(), stored on

      ClusterMetadata.priority_score.

Both signals you asked for are present; they just live at the layer

where they're actually capable of discriminating something.

 

Phase 14 stays inside M3's no-generation boundary: ClusterSummary and

every node's summary_text are deterministic and templated, never an

LLM call. Only Phase 15 touches an LLM — this phase produces the

ContextCluster that Phase 15 consumes, nothing more.

 

See models.py for the schemas, graph_traversal.py for the BFS

strategy, cluster_ranker.py for both scoring functions,

cluster_pruner.py for token-budget-aware fitting, cluster_explainer.py

for the deterministic audit trail, infrastructure/ for the

InfrastructureLinker abstraction (Terraform real-graph-backed today;

Kubernetes/GitHub Actions/GitLab CI/Docker/OpenAPI heuristic-text-

matched, second-class ArtifactNodes — never silently equated with a

real graph edge), and cluster_builder.py for the orchestrator.

"""

Meridian clustering models · PY

"""Phase 14 — ContextCluster, ClusterNode, ClusterEdge, ClusterSummary,

ClusterMetadata, ArtifactNode, ArtifactKind.

"""

from __future__ import annotations

 

import hashlib

from dataclasses import dataclass

from enum import Enum

 

 

class ClusterNodeKind(str, Enum):

    FUNCTION     = "function"

    CLASS        = "class"

    MODULE       = "module"

    RESOURCE     = "resource"        # Terraform — real graph node, real Fact backing

    DATA_SOURCE  = "data_source"     # Terraform — real graph node, real Fact backing

 

 

class ArtifactKind(str, Enum):

    """Second-class, unparsed infrastructure assets — Karthik's

    explicit override of the original "exclude until parsers exist"

    proposal. These never participate in graph traversal (no IR, no

    graph node exists for them) but can be heuristically attached to a

    cluster. parsed=False and confidence="low" on every ArtifactNode

    instance are not optional flags — they're structurally honest

    about what this layer actually knows about these files, which is

    "this text exists and matched a keyword," nothing more.

    """

    TERRAFORM        = "terraform"          # promoted to a real ClusterNode when linked — see terraform_linker.py

    KUBERNETES        = "kubernetes"

    GITHUB_ACTIONS     = "github_actions"

    GITLAB_CI            = "gitlab_ci"

    DOCKERFILE            = "dockerfile"

    DOCKER_COMPOSE          = "docker_compose"

    OPENAPI                  = "openapi"

    GENERIC_YAML               = "generic_yaml"

    GENERIC_JSON                 = "generic_json"

 

 

@dataclass(frozen=True, slots=True)

class ClusterNode:

    """A real, graph-traversed (or Terraform-heuristic-linked) node.

 

    parent_node_id is the BFS predecessor that discovered this node —

    None for hop_distance == 0 (anchors). Exists so cluster_explainer.py

    can reconstruct a real discovery chain ("Finding -> Evidence ->

    auth.py::login -> CALLS -> jwt.py::verify") instead of just listing

    nodes grouped by hop number with no notion of *which* hop-0 node

    led to them.

    """

    node_id:           str

    kind:              ClusterNodeKind

    rel_path:          str

    relevance_score:   float

    hop_distance:      int

    parent_node_id:    str | None

    discovered_via:     str | None     # EdgeType.value, or "heuristic_infra_link" for Terraform — never blank for hop>0

    inclusion:           str            # "full" | "summarized" | "dropped" — set by cluster_pruner

    source_text:          str | None

    summary_text:           str | None   # deterministic, templated — never LLM-generated

    fact_ids:                tuple[str, ...]

    estimated_tokens:          int

 

 

@dataclass(frozen=True, slots=True)

class ArtifactNode:

    """A heuristically-attached, unparsed infrastructure file. See

    ArtifactKind's docstring for why parsed/confidence are fixed, not

    configurable per instance — they describe a real, current

    limitation of this layer, not a per-file judgment call.

    """

    artifact_id:      str

    rel_path:          str

    artifact_kind:      ArtifactKind

    parsed:              bool    # always False today

    confidence:           str     # always "low" today

    match_reason:           str    # human-readable — WHY this got attached (keyword/co-location), for audit

    raw_excerpt:              str    # bounded excerpt, never the full file unconditionally

    estimated_tokens:           int

    inclusion:                    str = "full"   # artifacts are small by construction; pruner can still drop them

 

 

@dataclass(frozen=True, slots=True)

class ClusterEdge:

    source_id:   str

    target_id:   str

    edge_type:   str   # mirrors graph.edge_types.EdgeType.value for real edges;

                        # "heuristic_infra_link" for the Terraform-linking case —

                        # never silently indistinguishable from a real resolved edge

 

 

@dataclass(frozen=True, slots=True)

class ClusterSummary:

    """Deterministic, templated — NOT LLM-generated. Phase 14 stays

    inside M3's no-generation boundary; only Phase 15 touches an LLM."""

    cluster_id:          str

    text:                  str

    node_kind_counts:        dict[str, int]

    rel_paths:                 tuple[str, ...]

 

 

@dataclass(frozen=True, slots=True)

class ClusterMetadata:

    cluster_id:                 str

    finding_id:                  str

    anchor_node_ids:               tuple[str, ...]

    max_hops:                       int

    max_fanout_cap:                   int

    total_nodes_considered:             int    # before pruning

    total_nodes_included:                 int    # after pruning (full + summarized)

    total_artifacts_attached:               int

    estimated_tokens:                         int

    token_budget:                               int

    truncated:                                    bool    # True if pruning had to drop or summarize anything

    priority_score:                                 float   # cross-cluster priority — see package docstring

 

 

@dataclass(frozen=True, slots=True)

class ContextCluster:

    cluster_id:        str    # stable hash of finding_id — same stability convention as

                                # fact_id/claim_id/evidence_id/finding_id

    finding_id:           str

    nodes:                  tuple[ClusterNode, ...]

    edges:                     tuple[ClusterEdge, ...]

    artifact_nodes:               tuple[ArtifactNode, ...]

    summary:                       ClusterSummary

    metadata:                        ClusterMetadata

 

 

def stable_cluster_id(finding_id: str) -> str:

    return hashlib.sha256(f"cluster|{finding_id}".encode("utf-8")).hexdigest()[:24]

Meridian clustering token budget · PY

"""Phase 14 — token budgeting, provider/model-aware (Karthik's

amendment #3, replacing a bare context_window int).

 

Curated reference table, same disclaimer as the legacy config.py's

MODEL_CATALOGUE comment: current as of this build, providers add/

retire models — query their /models endpoint for an exhaustive live

list. Unknown models fall back to a deliberately conservative default

(8K) rather than guessing optimistically, since under-filling an

unknown model's real window is recoverable (smaller cluster, still

correct) while overflowing it is not.

"""

from __future__ import annotations

 

from dataclasses import dataclass

 

 

@dataclass(frozen=True, slots=True)

class ProviderContext:

    provider:               str

    model:                  str

    context_window:          int

    supports_reasoning:        bool

    supports_tool_calling:       bool

    supports_cache:                 bool

 

 

_KNOWN_MODEL_CONTEXTS: dict[str, ProviderContext] = {

    "claude-opus-4-8":   ProviderContext("anthropic", "claude-opus-4-8", 1_000_000, True, True, True),

    "claude-sonnet-4-6": ProviderContext("anthropic", "claude-sonnet-4-6", 1_000_000, True, True, True),

    "claude-haiku-4-5":  ProviderContext("anthropic", "claude-haiku-4-5", 200_000, False, True, True),

 

    "gpt-5.5-pro":  ProviderContext("openai", "gpt-5.5-pro", 1_000_000, True, True, False),

    "gpt-5.4-pro":  ProviderContext("openai", "gpt-5.4-pro", 256_000, True, True, False),

    "gpt-5.4-mini": ProviderContext("openai", "gpt-5.4-mini", 128_000, False, True, False),

    "gpt-5.4-nano": ProviderContext("openai", "gpt-5.4-nano", 64_000, False, True, False),

 

    "gemini/gemini-3.1-pro":        ProviderContext("gemini", "gemini/gemini-3.1-pro", 2_000_000, True, True, True),

    "gemini/gemini-3.5-flash":      ProviderContext("gemini", "gemini/gemini-3.5-flash", 1_000_000, False, True, True),

    "gemini/gemini-3.1-flash-lite": ProviderContext("gemini", "gemini/gemini-3.1-flash-lite", 1_000_000, False, True, False),

 

    "groq/llama-3.3-70b-versatile": ProviderContext("groq", "groq/llama-3.3-70b-versatile", 128_000, False, False, False),

    "groq/llama-3.1-8b-instant":    ProviderContext("groq", "groq/llama-3.1-8b-instant", 128_000, False, False, False),

 

    "ollama/llama3.3": ProviderContext("ollama", "ollama/llama3.3", 128_000, False, False, False),

    "ollama/mistral":  ProviderContext("ollama", "ollama/mistral", 32_000, False, False, False),

}

 

# Provider-level fallback when the exact model string isn't in the

# table above but the provider prefix is recognizable — still better

# than the global _default, since at least the provider's typical

# range is known.

_PROVIDER_FALLBACKS: dict[str, ProviderContext] = {

    "anthropic": ProviderContext("anthropic", "_unknown", 200_000, False, True, True),

    "openai":    ProviderContext("openai", "_unknown", 128_000, False, True, False),

    "gemini":    ProviderContext("gemini", "_unknown", 1_000_000, False, True, False),

    "groq":      ProviderContext("groq", "_unknown", 32_000, False, False, False),

    "ollama":    ProviderContext("ollama", "_unknown", 32_000, False, False, False),

}

 

_DEFAULT_CONTEXT = ProviderContext("_default", "_unknown", 8_000, False, False, False)

 

 

def _infer_provider(model: str) -> str:

    if "/" in model:

        return model.split("/")[0]

    if model.startswith("claude"):

        return "anthropic"

    if model.startswith(("gpt-", "o1", "o3", "o4")):

        return "openai"

    if model.startswith("gemini"):

        return "gemini"

    return "_unknown"

 

 

def resolve_provider_context(model: str) -> ProviderContext:

    """Never raises. Exact match -> provider-level fallback ->

    conservative global default, in that order."""

    if model in _KNOWN_MODEL_CONTEXTS:

        return _KNOWN_MODEL_CONTEXTS[model]

    provider = _infer_provider(model)

    if provider in _PROVIDER_FALLBACKS:

        fallback = _PROVIDER_FALLBACKS[provider]

        return ProviderContext(provider, model, fallback.context_window,

                                fallback.supports_reasoning, fallback.supports_tool_calling, fallback.supports_cache)

    return _DEFAULT_CONTEXT

 

 

@dataclass(frozen=True)

class TokenBudgetConfig:

    provider_context:               ProviderContext

    reserved_for_prompt_overhead:   int = 1500   # system preamble + finding/capability/control metadata

    reserved_for_response:           int = 1500   # headroom for the LLM's own output

 

    @property

    def usable_for_cluster_content(self) -> int:

        return max(0, self.provider_context.context_window

                    - self.reserved_for_prompt_overhead - self.reserved_for_response)

 

 

def estimate_tokens(text: str) -> int:

    """Same heuristic already proven in the legacy rate_limit.py:

    1 token ~= 3.5 chars, 20% safety buffer. No new tokenizer

    dependency needed for budgeting purposes — this is a planning

    estimate, not a billing-accurate count."""

    return int(len(text) / 3.5 * 1.20)

Meridian clustering graph traversal · PY

"""Phase 14 — graph traversal. Bounded BFS from a Finding's evidence-

derived anchors outward through the real RepositoryGraph.

 

Two independent stopping conditions, not one — max_hops alone doesn't

protect against fan-out (a function called from 50 places still

produces 50 hop-1 nodes before any pruning runs). max_fanout_cap is a

hard ceiling on total nodes visited, checked during the walk itself,

not after.

 

Traverses CONTAINS/CALLS/IMPORTS/EXTENDS in both directions (successors

AND predecessors) so both callers and callees are reachable — matching

the bidirectional traversal in the example diagram from Phase 14's

design review ("Callers" and "Callees" both listed as hops).

DEPENDS_ON is deliberately excluded: it's currently derived 1:1 from

resolved IMPORTS (see graph/edge_types.py), so including it would just

double-visit the same targets IMPORTS already reaches.

"""

from __future__ import annotations

 

from dataclasses import dataclass, field

 

from meridian.graph.edge_types import EdgeType

from meridian.graph.repository_graph import RepositoryGraph

 

_TRAVERSAL_EDGE_TYPES = (EdgeType.CONTAINS, EdgeType.CALLS, EdgeType.IMPORTS, EdgeType.EXTENDS)

 

DEFAULT_MAX_HOPS = 2

DEFAULT_MAX_FANOUT_CAP = 200

 

 

@dataclass(frozen=True)

class TraversalResult:

    hop_by_node:        dict[str, int]                    # node_id -> hop_distance (0 = anchor)

    parent_by_node:      dict[str, tuple[str, str]]          # node_id -> (parent_node_id, discovered_via)

    fanout_capped:          bool

 

 

def traverse(

    graph: RepositoryGraph,

    anchor_node_ids: list[str],

    max_hops: int = DEFAULT_MAX_HOPS,

    max_fanout_cap: int = DEFAULT_MAX_FANOUT_CAP,

) -> TraversalResult:

    hop_by_node: dict[str, int] = {}

    parent_by_node: dict[str, tuple[str, str]] = {}

    fanout_capped = False

 

    frontier: list[str] = []

    for node_id in anchor_node_ids:

        if graph.get_node(node_id) is not None and node_id not in hop_by_node:

            hop_by_node[node_id] = 0

            frontier.append(node_id)

 

    current_hop = 0

    while frontier and current_hop < max_hops:

        if len(hop_by_node) >= max_fanout_cap:

            fanout_capped = True

            break

 

        next_frontier: list[str] = []

        for node_id in frontier:

            for edge_type in _TRAVERSAL_EDGE_TYPES:

                for neighbor in graph.successors(node_id, edge_type=edge_type):

                    if len(hop_by_node) >= max_fanout_cap:

                        fanout_capped = True

                        break

                    if neighbor not in hop_by_node:

                        hop_by_node[neighbor] = current_hop + 1

                        parent_by_node[neighbor] = (node_id, edge_type.value)

                        next_frontier.append(neighbor)

                if len(hop_by_node) >= max_fanout_cap:

                    break

 

                for neighbor in graph.predecessors(node_id, edge_type=edge_type):

                    if len(hop_by_node) >= max_fanout_cap:

                        fanout_capped = True

                        break

                    if neighbor not in hop_by_node:

                        hop_by_node[neighbor] = current_hop + 1

                        parent_by_node[neighbor] = (node_id, f"reverse_{edge_type.value}")

                        next_frontier.append(neighbor)

                if len(hop_by_node) >= max_fanout_cap:

                    break

            if len(hop_by_node) >= max_fanout_cap:

                fanout_capped = True

                break

 

        frontier = next_frontier

        current_hop += 1

 

    return TraversalResult(hop_by_node=hop_by_node, parent_by_node=parent_by_node, fanout_capped=fanout_capped)

 
Meridian clustering cluster ranker · PY

"""Phase 14 — relevance scoring. Two separate functions, not one — see

the clustering package docstring for why control severity and

capability importance can't usefully appear in node-level scoring

(they're constant across every node in one cluster) and instead drive

a separate cluster-level priority score.

 

Both are documented heuristics with named weights, not derived

formulas — same honesty standard as data_minimization_detector.py's

fan-in threshold. Tunable; the weights are not regulatory or

scientific constants.

"""

from __future__ import annotations

 

from meridian.compliance.resolver import ComplianceGraphResolver

from meridian.graph.node_types import NodeType

from meridian.verdicts.models import Finding

 

_KIND_BONUS = {

    NodeType.FUNCTION: 0.3,

    NodeType.CLASS: 0.2,

    NodeType.RESOURCE: 0.3,

    NodeType.DATA_SOURCE: 0.3,

    NodeType.MODULE: 0.1,

    NodeType.VARIABLE: 0.05,

}

 

_SEVERITY_WEIGHT = {"Critical": 1.0, "High": 0.7, "Medium": 0.4, "Low": 0.1}

 

 

def score_node(

    hop_distance: int,

    has_direct_evidence: bool,

    pagerank_score: float,

    node_kind: NodeType,

    fact_confidence: float | None = None,

    detector_confidence: float | None = None,

) -> float:

    """Decides what survives pruning WITHIN one cluster. Eve
<truncated 88836 bytes>

NOTE: The output was truncated because it was too long. Use a more targeted query or a smaller range to get the information you need.