fd-open-data-protocol 0.2.0__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {fd_open_data_protocol-0.2.0 → fd_open_data_protocol-0.3.1}/PKG-INFO +93 -3
  2. {fd_open_data_protocol-0.2.0 → fd_open_data_protocol-0.3.1}/README.md +90 -2
  3. {fd_open_data_protocol-0.2.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/__init__.py +1 -1
  4. fd_open_data_protocol-0.3.1/fd_open_data_protocol/registry.py +274 -0
  5. fd_open_data_protocol-0.3.1/fd_open_data_protocol/registry_cli.py +239 -0
  6. fd_open_data_protocol-0.3.1/fd_open_data_protocol/registry_store.py +458 -0
  7. fd_open_data_protocol-0.3.1/fd_open_data_protocol/schema.py +403 -0
  8. fd_open_data_protocol-0.3.1/fd_open_data_protocol/vocabulary/concepts.yaml +230 -0
  9. fd_open_data_protocol-0.3.1/fd_open_data_protocol/vocabulary/crosswalks/datacommons-worldbank.yaml +51 -0
  10. fd_open_data_protocol-0.3.1/fd_open_data_protocol/vocabulary/entity_types.yaml +99 -0
  11. fd_open_data_protocol-0.3.1/fd_open_data_protocol/vocabulary/event_types.yaml +17 -0
  12. fd_open_data_protocol-0.3.1/fd_open_data_protocol/vocabulary/qualifiers.yaml +51 -0
  13. fd_open_data_protocol-0.3.1/fd_open_data_protocol/vocabulary/relation_types.yaml +73 -0
  14. fd_open_data_protocol-0.3.1/fd_open_data_protocol/vocabulary/units.yaml +94 -0
  15. fd_open_data_protocol-0.3.1/fd_open_data_protocol/vocabulary.py +349 -0
  16. {fd_open_data_protocol-0.2.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol.egg-info/PKG-INFO +93 -3
  17. fd_open_data_protocol-0.3.1/fd_open_data_protocol.egg-info/SOURCES.txt +29 -0
  18. fd_open_data_protocol-0.3.1/fd_open_data_protocol.egg-info/entry_points.txt +2 -0
  19. {fd_open_data_protocol-0.2.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol.egg-info/requires.txt +3 -0
  20. {fd_open_data_protocol-0.2.0 → fd_open_data_protocol-0.3.1}/pyproject.toml +9 -0
  21. {fd_open_data_protocol-0.2.0 → fd_open_data_protocol-0.3.1}/tests/test_loader.py +35 -0
  22. fd_open_data_protocol-0.3.1/tests/test_registry_pipeline.py +209 -0
  23. fd_open_data_protocol-0.3.1/tests/test_registry_schema.py +91 -0
  24. fd_open_data_protocol-0.3.1/tests/test_schema_vocabulary.py +86 -0
  25. fd_open_data_protocol-0.3.1/tests/test_vocabulary.py +186 -0
  26. fd_open_data_protocol-0.2.0/fd_open_data_protocol/schema.py +0 -216
  27. fd_open_data_protocol-0.2.0/fd_open_data_protocol.egg-info/SOURCES.txt +0 -13
  28. {fd_open_data_protocol-0.2.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/constants.py +0 -0
  29. {fd_open_data_protocol-0.2.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/loader.py +0 -0
  30. {fd_open_data_protocol-0.2.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/provider.py +0 -0
  31. {fd_open_data_protocol-0.2.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol.egg-info/dependency_links.txt +0 -0
  32. {fd_open_data_protocol-0.2.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol.egg-info/top_level.txt +0 -0
  33. {fd_open_data_protocol-0.2.0 → fd_open_data_protocol-0.3.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fd-open-data-protocol
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: The open-data datasource protocol: a manifest contract a datasource exposes to be ingested by fd-open-data-mcp (or any consumer).
5
5
  Author: FindDataOfficial
6
6
  License: MIT
@@ -10,9 +10,13 @@ Requires-Dist: pydantic>=2.0
10
10
  Requires-Dist: pyyaml>=6.0
11
11
  Provides-Extra: dev
12
12
  Requires-Dist: pytest>=8.0; extra == "dev"
13
+ Provides-Extra: registry
14
+ Requires-Dist: psycopg[binary]>=3.1; extra == "registry"
13
15
 
14
16
  # fd-open-data-protocol
15
17
 
18
+ > **Wire (柏讯) product line** · the open-data supply line of [FindData](https://www.finddatatech.cloud/products/wire) — the datasource manifest contract
19
+
16
20
  **English** | [中文](README.zh-CN.md)
17
21
 
18
22
  The **open-data datasource protocol**: a manifest contract a datasource exposes
@@ -99,7 +103,7 @@ my-source = "my_pkg.catalog:CATALOG"
99
103
  - **`DatasourceManifest`**: name, label, source_url, scanner_mode, ranking_seed, functions[], concepts[], entities[], entity_definitions[], relationships[], fetch.
100
104
  - **`FunctionSpec`**: command, category, description, parameters[], columns[], frequency, verified.
101
105
  - **`ColumnSpec`**: name, type, description, meaning, semantic_type, `frequency` + `datasource` (column-level).
102
- - **`ConceptHint`**: column, concept, `entity_type`, `measure`, unit, frequency, confidence.
106
+ - **`ConceptHint`**: column, concept, `entity_type`, `measure`, unit, frequency, confidence, `concept_family` (optional).
103
107
  - **`EntitySpec`**: entity_type, coverage ("universe"|"explicit"), codes[] (for explicit coverage).
104
108
  - **`Entity`**: entity_type, code, name_en, name_zh, metadata{}, relationships[].
105
109
  - **`EntityRelationship`**: target_entity_type, target_code, relation_type, confidence, metadata{}.
@@ -149,9 +153,33 @@ entity_definitions:
149
153
 
150
154
  When included, entities are registered in the ontology database during `register_datasource()`.
151
155
 
156
+ #### Reserved metadata key: `external_ids`
157
+
158
+ An entity definition may anchor itself in an external entity graph under the
159
+ reserved metadata key `external_ids`. Registration persists each entry as a
160
+ per-source identifier, so consumers can resolve the external id back to the
161
+ local entity.
162
+
163
+ ```yaml
164
+ entity_definitions:
165
+ - entity_type: country
166
+ code: CN
167
+ name_en: China
168
+ metadata:
169
+ external_ids:
170
+ wikidata: Q148 # Wikidata QID (Q followed by digits)
171
+ datacommons: country/CHN # Data Commons DCID
172
+ ```
173
+
174
+ Supported anchor sources are `wikidata` (QID, validated as `Q<digits>`) and
175
+ `datacommons` (non-empty DCID). A malformed or unknown anchor is logged and
176
+ skipped — it never fails registration.
177
+
152
178
  ### Canonical Entity Types
153
179
 
154
- All `entity_type` values must be from this vocabulary:
180
+ The entity-type vocabulary is defined by
181
+ [`fd_open_data_protocol/vocabulary/entity_types.yaml`](fd_open_data_protocol/vocabulary/entity_types.yaml)
182
+ (14 types) and is the authority for manifest validation:
155
183
 
156
184
  | Type | Description | Example IDs |
157
185
  |------|-------------|-------------|
@@ -165,7 +193,69 @@ All `entity_type` values must be from this vocabulary:
165
193
  | `crypto` | Cryptocurrencies | btc, eth |
166
194
  | `organization` | General orgs | org_code |
167
195
  | `industry` | Classifications | shenwan_1_01, gics_10 |
196
+ | `exchange` | Exchanges / venues | XNYS, XNAS, SSE |
168
197
  | `company` | Public companies | AAPL, TSLA |
198
+ | `commodity` | Raw goods | steel, oil, grain |
199
+ | `person` | Humans (fund managers) | person_code |
200
+
201
+ ## Semantic vocabulary
202
+
203
+ The protocol ships the publishable semantic core of FindData as versioned YAML
204
+ under [`fd_open_data_protocol/vocabulary/`](fd_open_data_protocol/vocabulary/):
205
+
206
+ | File | Registry |
207
+ |------|----------|
208
+ | `entity_types.yaml` | entity types (the manifest vocabulary above) |
209
+ | `concepts.yaml` | concept families + their seeded Variables |
210
+ | `qualifiers.yaml` | qualifiers (nominal_current, real_constant, ppp, per_capita, growth, ...) |
211
+ | `units.yaml` | units of measure (UN/CEFACT codes + ISO 4217 currencies) |
212
+ | `relation_types.yaml` | entity relationship types |
213
+ | `event_types.yaml` | event types (empty in this release) |
214
+ | `crosswalks/*.yaml` | Variable ↔ external-vocabulary assertions |
215
+
216
+ The vocabulary files ship as package data (`0.3.0`+), so `load_vocabulary()`
217
+ works from an installed wheel with no checkout on disk.
218
+
219
+ ```python
220
+ from fd_open_data_protocol.vocabulary import load_vocabulary, validate_vocabulary
221
+
222
+ vocab = load_vocabulary() # shipped files
223
+ vocab = load_vocabulary("/path/to/dir") # or an explicit directory
224
+ vocab.version # release version of the file set
225
+ vocab.get("concept", "GDP").uri # https://schema.finddata.tech/concept/GDP
226
+ vocab.entity_type_ids() # the manifest entity-type vocabulary
227
+ vocab.seeded_variables() # Variables a family declares
228
+ errors = validate_vocabulary() # [] when the file set is valid
229
+ ```
230
+
231
+ Every entry carries a stable URI of the form
232
+ `https://schema.finddata.tech/<kind>/<Id>`, where `<kind>` is one of
233
+ `entity-type`, `concept`, `qualifier`, `unit`, `relation-type`, `event-type`.
234
+
235
+ **Stability rules.** A URI is never reassigned to a different meaning and
236
+ entries are never deleted. Retirement is expressed with a `superseded_by`
237
+ pointer to the successor entry, which keeps the URI resolvable. The file set
238
+ carries one release `version`, and the loader rejects a file set whose files
239
+ disagree.
240
+
241
+ ## Concept families and Variables
242
+
243
+ Concepts are two-level. A **concept family** (`fd:GDP`, `fd:Population`) is the
244
+ small curated vocabulary from `concepts.yaml`; a **Variable** is the qualified
245
+ combination `(code, entity_type, measure, unit, frequency)` — the identity a
246
+ datasource actually binds columns to.
247
+
248
+ A manifest concept hint may point at its family with the optional
249
+ `concept_family` field, which the loader validates against `concepts.yaml`:
250
+
251
+ ```yaml
252
+ concepts:
253
+ - {column: close, concept: price.close, entity_type: stock, unit: currency,
254
+ frequency: daily, concept_family: PriceClose}
255
+ ```
256
+
257
+ Omitting it preserves the previous behaviour — no family validation occurs, and
258
+ the consumer derives a family from the concept code.
169
259
 
170
260
  ## Manifest Declaration Requirement
171
261
 
@@ -1,5 +1,7 @@
1
1
  # fd-open-data-protocol
2
2
 
3
+ > **Wire (柏讯) product line** · the open-data supply line of [FindData](https://www.finddatatech.cloud/products/wire) — the datasource manifest contract
4
+
3
5
  **English** | [中文](README.zh-CN.md)
4
6
 
5
7
  The **open-data datasource protocol**: a manifest contract a datasource exposes
@@ -86,7 +88,7 @@ my-source = "my_pkg.catalog:CATALOG"
86
88
  - **`DatasourceManifest`**: name, label, source_url, scanner_mode, ranking_seed, functions[], concepts[], entities[], entity_definitions[], relationships[], fetch.
87
89
  - **`FunctionSpec`**: command, category, description, parameters[], columns[], frequency, verified.
88
90
  - **`ColumnSpec`**: name, type, description, meaning, semantic_type, `frequency` + `datasource` (column-level).
89
- - **`ConceptHint`**: column, concept, `entity_type`, `measure`, unit, frequency, confidence.
91
+ - **`ConceptHint`**: column, concept, `entity_type`, `measure`, unit, frequency, confidence, `concept_family` (optional).
90
92
  - **`EntitySpec`**: entity_type, coverage ("universe"|"explicit"), codes[] (for explicit coverage).
91
93
  - **`Entity`**: entity_type, code, name_en, name_zh, metadata{}, relationships[].
92
94
  - **`EntityRelationship`**: target_entity_type, target_code, relation_type, confidence, metadata{}.
@@ -136,9 +138,33 @@ entity_definitions:
136
138
 
137
139
  When included, entities are registered in the ontology database during `register_datasource()`.
138
140
 
141
+ #### Reserved metadata key: `external_ids`
142
+
143
+ An entity definition may anchor itself in an external entity graph under the
144
+ reserved metadata key `external_ids`. Registration persists each entry as a
145
+ per-source identifier, so consumers can resolve the external id back to the
146
+ local entity.
147
+
148
+ ```yaml
149
+ entity_definitions:
150
+ - entity_type: country
151
+ code: CN
152
+ name_en: China
153
+ metadata:
154
+ external_ids:
155
+ wikidata: Q148 # Wikidata QID (Q followed by digits)
156
+ datacommons: country/CHN # Data Commons DCID
157
+ ```
158
+
159
+ Supported anchor sources are `wikidata` (QID, validated as `Q<digits>`) and
160
+ `datacommons` (non-empty DCID). A malformed or unknown anchor is logged and
161
+ skipped — it never fails registration.
162
+
139
163
  ### Canonical Entity Types
140
164
 
141
- All `entity_type` values must be from this vocabulary:
165
+ The entity-type vocabulary is defined by
166
+ [`fd_open_data_protocol/vocabulary/entity_types.yaml`](fd_open_data_protocol/vocabulary/entity_types.yaml)
167
+ (14 types) and is the authority for manifest validation:
142
168
 
143
169
  | Type | Description | Example IDs |
144
170
  |------|-------------|-------------|
@@ -152,7 +178,69 @@ All `entity_type` values must be from this vocabulary:
152
178
  | `crypto` | Cryptocurrencies | btc, eth |
153
179
  | `organization` | General orgs | org_code |
154
180
  | `industry` | Classifications | shenwan_1_01, gics_10 |
181
+ | `exchange` | Exchanges / venues | XNYS, XNAS, SSE |
155
182
  | `company` | Public companies | AAPL, TSLA |
183
+ | `commodity` | Raw goods | steel, oil, grain |
184
+ | `person` | Humans (fund managers) | person_code |
185
+
186
+ ## Semantic vocabulary
187
+
188
+ The protocol ships the publishable semantic core of FindData as versioned YAML
189
+ under [`fd_open_data_protocol/vocabulary/`](fd_open_data_protocol/vocabulary/):
190
+
191
+ | File | Registry |
192
+ |------|----------|
193
+ | `entity_types.yaml` | entity types (the manifest vocabulary above) |
194
+ | `concepts.yaml` | concept families + their seeded Variables |
195
+ | `qualifiers.yaml` | qualifiers (nominal_current, real_constant, ppp, per_capita, growth, ...) |
196
+ | `units.yaml` | units of measure (UN/CEFACT codes + ISO 4217 currencies) |
197
+ | `relation_types.yaml` | entity relationship types |
198
+ | `event_types.yaml` | event types (empty in this release) |
199
+ | `crosswalks/*.yaml` | Variable ↔ external-vocabulary assertions |
200
+
201
+ The vocabulary files ship as package data (`0.3.0`+), so `load_vocabulary()`
202
+ works from an installed wheel with no checkout on disk.
203
+
204
+ ```python
205
+ from fd_open_data_protocol.vocabulary import load_vocabulary, validate_vocabulary
206
+
207
+ vocab = load_vocabulary() # shipped files
208
+ vocab = load_vocabulary("/path/to/dir") # or an explicit directory
209
+ vocab.version # release version of the file set
210
+ vocab.get("concept", "GDP").uri # https://schema.finddata.tech/concept/GDP
211
+ vocab.entity_type_ids() # the manifest entity-type vocabulary
212
+ vocab.seeded_variables() # Variables a family declares
213
+ errors = validate_vocabulary() # [] when the file set is valid
214
+ ```
215
+
216
+ Every entry carries a stable URI of the form
217
+ `https://schema.finddata.tech/<kind>/<Id>`, where `<kind>` is one of
218
+ `entity-type`, `concept`, `qualifier`, `unit`, `relation-type`, `event-type`.
219
+
220
+ **Stability rules.** A URI is never reassigned to a different meaning and
221
+ entries are never deleted. Retirement is expressed with a `superseded_by`
222
+ pointer to the successor entry, which keeps the URI resolvable. The file set
223
+ carries one release `version`, and the loader rejects a file set whose files
224
+ disagree.
225
+
226
+ ## Concept families and Variables
227
+
228
+ Concepts are two-level. A **concept family** (`fd:GDP`, `fd:Population`) is the
229
+ small curated vocabulary from `concepts.yaml`; a **Variable** is the qualified
230
+ combination `(code, entity_type, measure, unit, frequency)` — the identity a
231
+ datasource actually binds columns to.
232
+
233
+ A manifest concept hint may point at its family with the optional
234
+ `concept_family` field, which the loader validates against `concepts.yaml`:
235
+
236
+ ```yaml
237
+ concepts:
238
+ - {column: close, concept: price.close, entity_type: stock, unit: currency,
239
+ frequency: daily, concept_family: PriceClose}
240
+ ```
241
+
242
+ Omitting it preserves the previous behaviour — no family validation occurs, and
243
+ the consumer derives a family from the concept code.
156
244
 
157
245
  ## Manifest Declaration Requirement
158
246
 
@@ -5,4 +5,4 @@ concept hints + fetch reference) so that fd-open-data-mcp - or any consumer -
5
5
  can ingest it via ``register_datasource``. Ship one manifest file -> the
6
6
  datasource is added; no consumer-side wiring.
7
7
  """
8
- __version__ = "0.2.0"
8
+ __version__ = "0.3.1"
@@ -0,0 +1,274 @@
1
+ """Declarative registry adapters and coverage computation.
2
+
3
+ An adapter is a small YAML file mapping a source table's rows onto
4
+ :class:`~fd_open_data_protocol.schema.RegistryEntry` fields - no core code
5
+ changes when a new source database is onboarded. The recipe (see REGISTRY.md):
6
+
7
+ 写适配器 → 批量登记(register)→ 概念绑定(bind)→ 命名门核验(verify)
8
+
9
+ Row sources are independent of mapping: rows can come from a Postgres source
10
+ DSN or a CSV file (tests, dry runs, the shipped template example), so the
11
+ whole pipeline is exercisable without a database.
12
+
13
+ Coverage is asset-denominated: per source "registered / actual in source",
14
+ with the denominator taken from the source database (or supplied counts) -
15
+ never from an MCP concept dictionary.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import csv
20
+ import json
21
+ from pathlib import Path
22
+ from typing import Any, Iterable, Optional, Sequence
23
+
24
+ import yaml
25
+ from pydantic import BaseModel, Field, field_validator
26
+
27
+ from fd_open_data_protocol.schema import RegistryEntry
28
+
29
+ #: registry fields an adapter may map; anchors come from dedicated keys.
30
+ MAPPABLE_FIELDS = ("semantic_code", "name_zh", "name_en", "unit", "frequency", "domain")
31
+
32
+ #: sources known to the fleet but NOT in the first registration batch; the
33
+ #: coverage view must show them explicitly instead of silently omitting them.
34
+ DEFAULT_SCOPE: dict[str, dict[str, Any]] = {
35
+ "world_bank": {"phase": 1, "label": "世界银行 WDI(含宏观数据链路)"},
36
+ "yearbook_catalog": {"phase": 1, "label": "国家统计年鉴"},
37
+ "gta_panel": {"phase": 1, "label": "GTA 治理面板"},
38
+ "china_city_panel": {"phase": 1, "label": "中国城市面板"},
39
+ "law_db": {
40
+ "phase": 1,
41
+ "label": "法律库·文档域(法条/切片/指导案例为文档资产,非指标口径;登记 0 属预期,经 business-mcp 可查)",
42
+ },
43
+ "fd_open_data": {"phase": 1, "label": "fd_open_data 镜像概念字典"},
44
+ "ia35": {"phase": 2, "label": "工商注册 161G(实体为主,二期)"},
45
+ "wenshu_db": {"phase": 2, "label": "裁判文书(增量暂停,二期)"},
46
+ }
47
+
48
+
49
+ class FieldSource(BaseModel):
50
+ """Where one registry field's value comes from.
51
+
52
+ ``from: column`` - a column of the source row (or CSV header)
53
+ ``from: literal`` - a constant (e.g. ``domain: macro``)
54
+ ``from: map_file``- a JSON ``{native_code: value}`` lookup, typically a
55
+ curated/LLM-drafted naming map (name translations,
56
+ semantic codes); misses fall back to ``missing``
57
+ """
58
+
59
+ from_: str = Field(alias="from")
60
+ value: str
61
+ missing: Optional[str] = None # value to store on a miss; default None = absent
62
+ uppercase: bool = False
63
+
64
+ @field_validator("from_")
65
+ @classmethod
66
+ def _known_kind(cls, v: str) -> str:
67
+ if v not in ("column", "literal", "map_file"):
68
+ raise ValueError(f"unknown field source kind: {v!r} (column|literal|map_file)")
69
+ return v
70
+
71
+ model_config = {"populate_by_name": True}
72
+
73
+
74
+ class AdapterConfig(BaseModel):
75
+ """Declarative source-table → registry mapping."""
76
+
77
+ source_db: str
78
+ source_table: str
79
+ source_column: str = "" # anchor label: the column the native code was read from
80
+ native_code_column: str
81
+ fields: dict[str, FieldSource]
82
+ provenance_columns: list[str] = Field(default_factory=list)
83
+ source_query: Optional[str] = None # default: SELECT * FROM <source_table>
84
+ semantic_code_dedupe_suffix: str = "-%d" # in-batch collision suffix (marked needs_review)
85
+
86
+ @field_validator("fields")
87
+ @classmethod
88
+ def _fields_known(cls, v: dict[str, FieldSource]) -> dict[str, FieldSource]:
89
+ unknown = set(v) - set(MAPPABLE_FIELDS)
90
+ if unknown:
91
+ raise ValueError(f"unknown mappable field(s): {sorted(unknown)}")
92
+ return v
93
+
94
+
95
+ def load_adapter(source: str | Path | dict) -> AdapterConfig:
96
+ """Load an adapter from a YAML/JSON file path or an already-parsed dict."""
97
+ if isinstance(source, dict):
98
+ return AdapterConfig(**source)
99
+ p = Path(source)
100
+ data = yaml.safe_load(p.read_text(encoding="utf-8"))
101
+ if not isinstance(data, dict):
102
+ raise ValueError(f"adapter file did not parse to a mapping: {p}")
103
+ adapter = AdapterConfig(**data)
104
+ adapter._base_dir = p.parent # type: ignore[attr-defined] # resolve relative map_files
105
+ return adapter
106
+
107
+
108
+ def _lookup(
109
+ spec: FieldSource, row: dict, native_code: str, base_dir: Path
110
+ ) -> Optional[str]:
111
+ value: Any
112
+ if spec.from_ == "literal":
113
+ value = spec.value
114
+ elif spec.from_ == "column":
115
+ value = row.get(spec.value)
116
+ else: # map_file
117
+ map_path = (base_dir / spec.value) if not Path(spec.value).is_absolute() else Path(spec.value)
118
+ try:
119
+ mapping = json.loads(map_path.read_text(encoding="utf-8"))
120
+ except FileNotFoundError:
121
+ value = spec.missing
122
+ mapping = None
123
+ else:
124
+ value = mapping.get(native_code, spec.missing)
125
+ if value is None:
126
+ return None
127
+ text = str(value).strip()
128
+ if not text:
129
+ return None
130
+ return text.upper() if spec.uppercase else text
131
+
132
+
133
+ def map_rows(
134
+ adapter: AdapterConfig, rows: Iterable[dict[str, Any]]
135
+ ) -> list[RegistryEntry]:
136
+ """Map source rows to registry entries (pure; no store access).
137
+
138
+ In-batch semantic_code collisions get a numeric suffix and a
139
+ ``needs_review: true`` provenance flag - never a silent merge.
140
+ """
141
+ base_dir = getattr(adapter, "_base_dir", Path("."))
142
+ seen_codes: dict[str, int] = {}
143
+ entries: list[RegistryEntry] = []
144
+ for row in rows:
145
+ native_code = str(row.get(adapter.native_code_column, "") or "").strip()
146
+ if not native_code:
147
+ raise ValueError(
148
+ f"row has empty {adapter.native_code_column!r}; adapter={adapter.source_db}.{adapter.source_table}"
149
+ )
150
+ fields: dict[str, Optional[str]] = {}
151
+ needs_review = False
152
+ for name, spec in adapter.fields.items():
153
+ value = _lookup(spec, row, native_code, base_dir)
154
+ if name == "semantic_code" and value is not None:
155
+ base = value
156
+ n = seen_codes.get(base, 0)
157
+ if n:
158
+ value = base + (adapter.semantic_code_dedupe_suffix % (n + 1))
159
+ needs_review = True
160
+ seen_codes[base] = n + 1
161
+ fields[name] = value
162
+ provenance = {c: row.get(c) for c in adapter.provenance_columns if c in row}
163
+ if needs_review:
164
+ provenance["needs_review"] = True
165
+ entries.append(
166
+ RegistryEntry(
167
+ source_db=adapter.source_db,
168
+ source_table=adapter.source_table,
169
+ source_column=adapter.source_column or adapter.native_code_column,
170
+ native_code=native_code,
171
+ provenance=provenance or None,
172
+ **fields,
173
+ )
174
+ )
175
+ return entries
176
+
177
+
178
+ def fetch_rows(adapter: AdapterConfig, source_dsn: str) -> list[dict[str, Any]]:
179
+ """Read all source rows via Postgres (psycopg3, dict rows)."""
180
+ import psycopg
181
+
182
+ sql = adapter.source_query or f"SELECT * FROM {adapter.source_table}"
183
+ with psycopg.connect(source_dsn) as conn:
184
+ with conn.cursor() as cur:
185
+ cur.execute(sql)
186
+ cols = [d.name for d in cur.description]
187
+ return [dict(zip(cols, r)) for r in cur.fetchall()]
188
+
189
+
190
+ def read_rows_csv(path: str | Path) -> list[dict[str, Any]]:
191
+ """Read source rows from a CSV (headers = column names). DB-free path."""
192
+ with open(path, encoding="utf-8-sig", newline="") as fh:
193
+ return [{k: v for k, v in row.items() if k is not None} for row in csv.DictReader(fh)]
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Coverage (asset-denominated)
198
+
199
+
200
+ def count_source_rows(adapter: AdapterConfig, source_dsn: str) -> int:
201
+ """Actual indicator rows in the source table - the coverage denominator."""
202
+ import psycopg
203
+
204
+ table = adapter.source_table
205
+ with psycopg.connect(source_dsn) as conn:
206
+ with conn.cursor() as cur:
207
+ cur.execute(f"SELECT count(*) FROM {table}")
208
+ return cur.fetchone()[0]
209
+
210
+
211
+ def coverage_report(
212
+ store,
213
+ actual_counts: Optional[dict[str, int]] = None,
214
+ scope: Optional[dict[str, dict[str, Any]]] = None,
215
+ module_map: Optional[dict[str, dict[str, list[str]]]] = None,
216
+ ) -> dict[str, Any]:
217
+ """Per-source registered/actual coverage + phase-2 visibility + unmapped audit.
218
+
219
+ ``actual_counts``: {source_db: actual rows} - from the source databases
220
+ themselves (``count_source_rows``) or supplied by the caller.
221
+ ``module_map``: {"module": {"source_dbs": [...]}} - registered entries not
222
+ matched by any module rule are listed as unmapped.
223
+ """
224
+ actual_counts = actual_counts or {}
225
+ scope = scope or DEFAULT_SCOPE
226
+ registered_by_db: dict[str, dict[str, int]] = {}
227
+ for (source_db, _source_table), counts in store.count_by_source().items():
228
+ bucket = registered_by_db.setdefault(source_db, {"registered": 0, "verified": 0})
229
+ bucket["registered"] += counts["registered"]
230
+ bucket["verified"] += counts["verified"]
231
+
232
+ sources: list[dict[str, Any]] = []
233
+ visible = (
234
+ set(registered_by_db)
235
+ | set(actual_counts)
236
+ | {s for s, v in scope.items() if v.get("phase") == 2}
237
+ )
238
+ for source_db in sorted(visible):
239
+ reg = registered_by_db.get(source_db, {"registered": 0, "verified": 0})
240
+ actual = actual_counts.get(source_db)
241
+ info = scope.get(source_db, {})
242
+ sources.append(
243
+ {
244
+ "source_db": source_db,
245
+ "label": info.get("label", ""),
246
+ "phase": info.get("phase", 1),
247
+ "registered": reg["registered"],
248
+ "verified": reg["verified"],
249
+ "unverified": reg["registered"] - reg["verified"],
250
+ "actual_in_source": actual,
251
+ "coverage": (
252
+ round(reg["registered"] / actual, 4) if actual else None
253
+ ),
254
+ }
255
+ )
256
+
257
+ report: dict[str, Any] = {"sources": sources}
258
+ if module_map:
259
+ unmapped = []
260
+ covered_dbs = {db for m in module_map.values() for db in m.get("source_dbs", [])}
261
+ for entry in store.iter_entries():
262
+ if entry.source_db not in covered_dbs:
263
+ unmapped.append(
264
+ {
265
+ "source_db": entry.source_db,
266
+ "native_code": entry.native_code,
267
+ "semantic_code": entry.semantic_code,
268
+ "name_zh": entry.name_zh,
269
+ }
270
+ )
271
+ report["module_map"] = module_map
272
+ report["unmapped_count"] = len(unmapped)
273
+ report["unmapped_sample"] = unmapped[:200]
274
+ return report