ontometer 0.1.0__py3-none-any.whl

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 (52) hide show
  1. ontometer/__init__.py +42 -0
  2. ontometer/assessment.py +104 -0
  3. ontometer/cli/__init__.py +1 -0
  4. ontometer/cli/_entry.py +19 -0
  5. ontometer/cli/main.py +172 -0
  6. ontometer/corpus/README.md +260 -0
  7. ontometer/corpus/__init__.py +29 -0
  8. ontometer/corpus/fetchers/__init__.py +15 -0
  9. ontometer/corpus/fetchers/_base.py +718 -0
  10. ontometer/corpus/fetchers/lov.py +245 -0
  11. ontometer/corpus/fetchers/obo.py +162 -0
  12. ontometer/corpus/fetchers/okg.py +310 -0
  13. ontometer/corpus/fetchers/ontohub.py +179 -0
  14. ontometer/corpus/fetchers/ontoportal.py +350 -0
  15. ontometer/corpus/metadata.py +129 -0
  16. ontometer/corpus/network/README.md +207 -0
  17. ontometer/corpus/network/__init__.py +1 -0
  18. ontometer/corpus/network/construction.py +386 -0
  19. ontometer/corpus/paths.py +204 -0
  20. ontometer/corpus/pipeline/README.md +350 -0
  21. ontometer/corpus/pipeline/__init__.py +30 -0
  22. ontometer/corpus/pipeline/db.py +594 -0
  23. ontometer/corpus/pipeline/dedup.py +427 -0
  24. ontometer/corpus/pipeline/parse.py +2329 -0
  25. ontometer/evaluation.py +77 -0
  26. ontometer/metrics/__init__.py +15 -0
  27. ontometer/metrics/hierarchy.py +146 -0
  28. ontometer/metrics/oquare.py +237 -0
  29. ontometer/metrics/seeds.py +81 -0
  30. ontometer/model/__init__.py +10 -0
  31. ontometer/model/load.py +121 -0
  32. ontometer/model/quality_model.yaml +87 -0
  33. ontometer/model/scales.yaml +45 -0
  34. ontometer/onto/__init__.py +14 -0
  35. ontometer/onto/load.py +125 -0
  36. ontometer/onto/namespaces.py +93 -0
  37. ontometer/onto/obo.py +149 -0
  38. ontometer/onto/view.py +259 -0
  39. ontometer/report/__init__.py +5 -0
  40. ontometer/report/render.py +179 -0
  41. ontometer/review/__init__.py +14 -0
  42. ontometer/review/findings.py +49 -0
  43. ontometer/review/llm.py +187 -0
  44. ontometer/review/prompt.py +87 -0
  45. ontometer/review/run.py +103 -0
  46. ontometer/settings.py +62 -0
  47. ontometer-0.1.0.dist-info/METADATA +263 -0
  48. ontometer-0.1.0.dist-info/RECORD +52 -0
  49. ontometer-0.1.0.dist-info/WHEEL +4 -0
  50. ontometer-0.1.0.dist-info/entry_points.txt +2 -0
  51. ontometer-0.1.0.dist-info/licenses/LICENSE +201 -0
  52. ontometer-0.1.0.dist-info/licenses/NOTICE +15 -0
ontometer/__init__.py ADDED
@@ -0,0 +1,42 @@
1
+ """OntoMeter — ontology quality assessment.
2
+
3
+ Evaluates a single ontology against the OQuaRE quality model and, where a
4
+ language model is configured, adds a qualitative review of the things structural
5
+ metrics cannot see.
6
+
7
+ >>> from ontometer import evaluate
8
+ >>> result = evaluate("my-ontology.ttl", with_review=False)
9
+ >>> result.assessment.metrics["DITOnto"].score
10
+ 5
11
+
12
+ The corpus-scale research half lives in :mod:`ontometer.corpus` and needs the
13
+ ``corpus`` extra.
14
+ """
15
+
16
+ from ontometer.assessment import Assessment, MetricResult, assess
17
+ from ontometer.evaluation import Evaluation, evaluate
18
+ from ontometer.onto.load import LoadError, load_graph, load_view
19
+ from ontometer.onto.view import OntologyView, build_view
20
+ from ontometer.review.findings import Finding, ReviewReport, Severity
21
+ from ontometer.settings import LLMConfig, LLMProvider
22
+
23
+ __version__ = "0.1.0"
24
+
25
+ __all__ = [
26
+ "Assessment",
27
+ "Evaluation",
28
+ "Finding",
29
+ "LLMConfig",
30
+ "LLMProvider",
31
+ "LoadError",
32
+ "MetricResult",
33
+ "OntologyView",
34
+ "ReviewReport",
35
+ "Severity",
36
+ "__version__",
37
+ "assess",
38
+ "build_view",
39
+ "evaluate",
40
+ "load_graph",
41
+ "load_view",
42
+ ]
@@ -0,0 +1,104 @@
1
+ """Rolling raw metrics up into scores, sub-characteristics and characteristics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from statistics import fmean
7
+
8
+ from ontometer.metrics.oquare import METRICS_BY_CODE, compute_metrics
9
+ from ontometer.metrics.seeds import seed_terms
10
+ from ontometer.model.load import QualityModel, Scales, load_quality_model, load_scales
11
+ from ontometer.onto.view import OntologyView
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class MetricResult:
16
+ """One metric's raw value, its 1-5 score, and how it was computed."""
17
+
18
+ code: str
19
+ name: str
20
+ formula: str
21
+ value: float
22
+ score: int
23
+ family: str
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class Assessment:
28
+ """Everything computed about one ontology, before any model is consulted."""
29
+
30
+ metrics: dict[str, MetricResult] = field(default_factory=dict)
31
+ subcharacteristics: dict[str, float] = field(default_factory=dict)
32
+ characteristics: dict[str, float] = field(default_factory=dict)
33
+ unsupported: dict[str, list[str]] = field(default_factory=dict)
34
+ seeds: dict[str, list[str]] = field(default_factory=dict)
35
+ aggregation: str = "mean"
36
+
37
+ @property
38
+ def overall(self) -> float:
39
+ """Mean of the characteristic scores.
40
+
41
+ OQuaRE defines no overall score. This one exists because a single number
42
+ is what makes two runs comparable at a glance, and it is computed the
43
+ same way as every other rollup — but it is the weakest claim in the
44
+ report and should not be read as a verdict.
45
+ """
46
+ values = list(self.characteristics.values())
47
+ return fmean(values) if values else 0.0
48
+
49
+ def worst_metrics(self, count: int = 5) -> list[MetricResult]:
50
+ """The *count* lowest-scoring metrics, worst first.
51
+
52
+ Ties break on the raw value's distance from its nearest band edge, so
53
+ the ordering is stable rather than dictionary order.
54
+ """
55
+ return sorted(self.metrics.values(), key=lambda m: (m.score, m.code))[:count]
56
+
57
+ def strongest_metrics(self, count: int = 3) -> list[MetricResult]:
58
+ return sorted(self.metrics.values(), key=lambda m: (-m.score, m.code))[:count]
59
+
60
+
61
+ def assess(
62
+ view: OntologyView,
63
+ model: QualityModel | None = None,
64
+ scales: Scales | None = None,
65
+ ) -> Assessment:
66
+ """Compute metrics for *view* and roll them up through the quality model."""
67
+ model = model or load_quality_model()
68
+ scales = scales or load_scales()
69
+
70
+ raw = compute_metrics(view)
71
+ results: dict[str, MetricResult] = {}
72
+ for code, value in raw.items():
73
+ spec = METRICS_BY_CODE[code]
74
+ results[code] = MetricResult(
75
+ code=code,
76
+ name=spec.name,
77
+ formula=spec.formula,
78
+ value=value,
79
+ score=scales.score(code, value),
80
+ family=scales.family_of(code),
81
+ )
82
+
83
+ subcharacteristics: dict[str, float] = {}
84
+ characteristics: dict[str, float] = {}
85
+ for char_name, (_, subs) in model.characteristics.items():
86
+ char_scores: list[float] = []
87
+ for sub_name, codes in subs.items():
88
+ supporting = [results[c].score for c in codes if c in results]
89
+ if not supporting:
90
+ continue
91
+ score = fmean(supporting)
92
+ subcharacteristics[f"{char_name}.{sub_name}"] = score
93
+ char_scores.append(score)
94
+ if char_scores:
95
+ characteristics[char_name] = fmean(char_scores)
96
+
97
+ return Assessment(
98
+ metrics=results,
99
+ subcharacteristics=subcharacteristics,
100
+ characteristics=characteristics,
101
+ unsupported=model.unsupported_subcharacteristics(),
102
+ seeds=seed_terms(view),
103
+ aggregation=model.aggregation,
104
+ )
@@ -0,0 +1 @@
1
+ """Command-line entry points."""
@@ -0,0 +1,19 @@
1
+ """Console-script shims.
2
+
3
+ Every entry point goes through here so that a missing optional dependency
4
+ produces a sentence telling the user what to install, rather than a traceback
5
+ ending in a module name they have no reason to recognise.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+
12
+
13
+ def ontometer() -> None:
14
+ """Entry point for the ``ontometer`` command."""
15
+ try:
16
+ from ontometer.cli.main import cli
17
+ except ImportError as exc: # pragma: no cover - depends on install shape
18
+ sys.exit(f"ontometer could not start: {exc}")
19
+ cli()
ontometer/cli/main.py ADDED
@@ -0,0 +1,172 @@
1
+ """The ``ontometer`` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from ontometer import __version__
12
+ from ontometer.evaluation import evaluate as run_evaluate
13
+ from ontometer.model.load import load_quality_model, load_scales
14
+ from ontometer.onto.load import LoadError
15
+ from ontometer.report.render import to_dict, to_markdown
16
+ from ontometer.settings import LLMConfig, LLMProvider
17
+
18
+
19
+ @click.group()
20
+ @click.version_option(__version__, prog_name="ontometer")
21
+ def cli() -> None:
22
+ """Ontology quality assessment."""
23
+
24
+
25
+ @cli.command("eval")
26
+ @click.argument("path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
27
+ @click.option(
28
+ "--format",
29
+ "output_format",
30
+ type=click.Choice(["md", "json"]),
31
+ default="md",
32
+ show_default=True,
33
+ help="Report format.",
34
+ )
35
+ @click.option(
36
+ "-o",
37
+ "--output",
38
+ type=click.Path(dir_okay=False, path_type=Path),
39
+ default=None,
40
+ help="Write the report here instead of stdout.",
41
+ )
42
+ @click.option(
43
+ "--review/--no-review",
44
+ default=True,
45
+ show_default=True,
46
+ help="Run the qualitative LLM review. Metrics are computed either way.",
47
+ )
48
+ @click.option(
49
+ "--provider",
50
+ type=click.Choice([p.value for p in LLMProvider]),
51
+ default=None,
52
+ help="Override LLM_PROVIDER.",
53
+ )
54
+ @click.option("--model", "model_name", default=None, help="Override LLM_MODEL_NAME.")
55
+ @click.option("--base-url", default=None, help="Override LLM_BASE_URL.")
56
+ @click.option(
57
+ "--instruction",
58
+ default="",
59
+ help="Extra guidance passed through to the review, e.g. a domain focus.",
60
+ )
61
+ @click.option(
62
+ "--fail-under",
63
+ type=float,
64
+ default=None,
65
+ help="Exit non-zero if the overall score falls below this. For CI.",
66
+ )
67
+ def eval_command(
68
+ path: Path,
69
+ output_format: str,
70
+ output: Path | None,
71
+ review: bool,
72
+ provider: str | None,
73
+ model_name: str | None,
74
+ base_url: str | None,
75
+ instruction: str,
76
+ fail_under: float | None,
77
+ ) -> None:
78
+ """Evaluate the ontology at PATH."""
79
+ # Start from the environment, then apply only the flags that were given.
80
+ # Splatting a dict into the settings model would work but erases every
81
+ # field type, so each override is assigned by name.
82
+ config = LLMConfig()
83
+ if provider:
84
+ config.provider = LLMProvider(provider)
85
+ if model_name:
86
+ config.model_name = model_name
87
+ if base_url:
88
+ config.base_url = base_url
89
+
90
+ try:
91
+ evaluation = run_evaluate(
92
+ path,
93
+ with_review=review,
94
+ config=config,
95
+ user_instruction=instruction,
96
+ )
97
+ except LoadError as exc:
98
+ raise click.ClickException(str(exc)) from exc
99
+
100
+ rendered = (
101
+ json.dumps(to_dict(evaluation), indent=2)
102
+ if output_format == "json"
103
+ else to_markdown(evaluation)
104
+ )
105
+
106
+ if output:
107
+ output.write_text(rendered, encoding="utf-8")
108
+ click.echo(f"Wrote {output}", err=True)
109
+ else:
110
+ click.echo(rendered)
111
+
112
+ if evaluation.review_skipped_reason and review:
113
+ click.echo(f"note: review not run — {evaluation.review_skipped_reason}", err=True)
114
+
115
+ if fail_under is not None and evaluation.assessment.overall < fail_under:
116
+ click.echo(
117
+ f"overall {evaluation.assessment.overall:.2f} is below --fail-under {fail_under}",
118
+ err=True,
119
+ )
120
+ sys.exit(1)
121
+
122
+
123
+ @cli.command("model")
124
+ @click.option(
125
+ "--what",
126
+ type=click.Choice(["metrics", "scales", "characteristics", "unsupported"]),
127
+ default="metrics",
128
+ show_default=True,
129
+ help="Which part of the quality model to print.",
130
+ )
131
+ def model_command(what: str) -> None:
132
+ """Show the quality model this build uses.
133
+
134
+ The model is configuration, not code — this is how you read what it
135
+ currently says before overriding it.
136
+ """
137
+ if what == "metrics":
138
+ from ontometer.metrics.oquare import METRICS
139
+
140
+ for spec in METRICS:
141
+ click.echo(f"{spec.code:<10} {spec.name:<34} {spec.formula}")
142
+ return
143
+
144
+ if what == "scales":
145
+ scales = load_scales()
146
+ for name, family in scales.families.items():
147
+ click.echo(f"[{name}] better = {family.direction}, cut points = {family.bounds}")
148
+ click.echo("")
149
+ for code, family_name in sorted(scales.metric_family.items()):
150
+ click.echo(f"{code:<10} {family_name}")
151
+ return
152
+
153
+ model = load_quality_model()
154
+ if what == "characteristics":
155
+ for name, (title, subs) in model.characteristics.items():
156
+ click.echo(f"\n{title} ({name})")
157
+ for sub, codes in subs.items():
158
+ shown = ", ".join(codes) if codes else "— no supporting metric —"
159
+ click.echo(f" {sub:<34} {shown}")
160
+ return
161
+
162
+ unsupported = model.unsupported_subcharacteristics()
163
+ total = sum(len(v) for v in unsupported.values())
164
+ click.echo(
165
+ f"{total} sub-characteristics have no supporting metric.\n"
166
+ f"These are not oversights — they are the parts of ontology quality that\n"
167
+ f"are not a function of the graph's shape, and are what the review covers.\n"
168
+ )
169
+ for name, subs in unsupported.items():
170
+ click.echo(f"{name}:")
171
+ for sub in subs:
172
+ click.echo(f" - {sub}")
@@ -0,0 +1,260 @@
1
+ # The corpus: acquisition, processing, and what can be reproduced
2
+
3
+ The corpus is fetched from ten registries, parsed, deduplicated, and turned into
4
+ a dependency network. This document is the shortest path from a clean checkout
5
+ to a rebuilt network, and an honest account of which parts of that are
6
+ reproducible and which are not.
7
+
8
+ ## 1. The pipeline end to end
9
+
10
+ ```mermaid
11
+ flowchart LR
12
+ REG["<b>Ten registries</b><br/>OntoPortal ×6 · OBO Foundry<br/>Ontohub · OKG · LOV"]
13
+
14
+ subgraph S1["Stage 1 — fetch_ontologies.py"]
15
+ direction TB
16
+ OBJ[("objects/&lt;hh&gt;/&lt;sha256&gt;<br/>raw files, content-addressed")]
17
+ MAN[["manifest.jsonl<br/>one line per attempt"]]
18
+ end
19
+
20
+ subgraph S2["Stage 2 — process.py"]
21
+ direction TB
22
+ DB[("corpus.db<br/>corpus_registry<br/>+ fetch_attempts")]
23
+ SIDE[("structure/&lt;hh&gt;/&lt;hash&gt;.parquet<br/>subclass edges + entities")]
24
+ end
25
+
26
+ subgraph S3["Stage 3 — derived"]
27
+ direction TB
28
+ NET[["network_nodes.parquet<br/>network_edges.parquet"]]
29
+ SM[["structural_metrics.parquet"]]
30
+ TC[["term_coupling.parquet"]]
31
+ end
32
+
33
+ REG --> OBJ
34
+ REG --> MAN
35
+ MAN -->|"Phase 0 seed"| DB
36
+ OBJ -->|"Phase 1 parse"| DB
37
+ OBJ -->|"Phase 1 parse"| SIDE
38
+ DB -->|"Phase 2 dedup<br/>file hash → IRI → content hash"| DB
39
+
40
+ DB --> NET
41
+ SIDE --> SM
42
+ OBJ --> TC
43
+
44
+ NET --> NB["research/<br/>analysis.ipynb"]
45
+ SM --> NB
46
+ TC --> NB
47
+
48
+ classDef tracked fill:#224777,stroke:#293241,color:#fff
49
+ classDef untracked fill:#E67E22,stroke:#293241,color:#fff
50
+ class MAN,DB,NET,SM,TC tracked
51
+ class OBJ,SIDE untracked
52
+ ```
53
+
54
+ **Blue is tracked in git. Orange is not** — it lives under `$ONTOLOGY_DIR` and is
55
+ far too large for a repository.
56
+
57
+ The diagram shows the dominant data flow. Two details it elides: both metric
58
+ scripts also read `corpus.db` for the list of ontologies to process, and
59
+ `term_coupling.py` re-parses the raw files rather than reading the sidecars.
60
+
61
+ ## 2. Where everything lives
62
+
63
+ There are exactly **two** input locations, and every other path is derived from
64
+ them. Nothing is computed from `__file__` and nothing has a hardcoded default
65
+ pointing into someone's home directory; see `ontometer/corpus/paths.py`.
66
+
67
+ | Setting | Flag | Environment | Default |
68
+ |---|---|---|---|
69
+ | Tracked artifacts | `--data-dir` | `ONTOMETER_DATA_DIR` | `./data` |
70
+ | Object store | `--ontology-dir` | `ONTOLOGY_DIR` | — (required by the stages that read raw files) |
71
+
72
+ ```
73
+ --data-dir/ --ontology-dir/
74
+ ├── corpus.db ├── objects/<hh>/<sha256><ext>
75
+ ├── manifest.jsonl └── structure/<hh>/<content_hash>.parquet
76
+ ├── network_nodes.parquet
77
+ ├── network_edges.parquet ../_parse_scratch/ (sibling of the store)
78
+ ├── structural_metrics.parquet
79
+ ├── term_coupling.parquet
80
+ ├── fetch_failures.log
81
+ └── dedup_exclusions.log
82
+ ```
83
+
84
+ Every script takes both flags, so a run can be pointed anywhere:
85
+
86
+ ```bash
87
+ uv run python run/process.py --data-dir /tmp/run1 --ontology-dir ~/data/ontologies
88
+ ```
89
+
90
+ Only **fetch**, **parse**, and the two metric scripts need the object store.
91
+ Seeding, dedup and network construction work from the tracked artifacts alone.
92
+
93
+ ## 3. What is tracked, and why
94
+
95
+ `data/` is committed. That is deliberate, and it is not the usual "derived
96
+ artifacts don't belong in git" case:
97
+
98
+ | Artifact | Size | Regenerable? |
99
+ |---|---|---|
100
+ | `corpus.db` | ~34 MiB | Only from the raw object store — see §4 |
101
+ | `manifest.jsonl` | ~13 MiB | No. It is the record of every fetch attempt, including failures and registries since dropped |
102
+ | `network_*.parquet` | ~1.3 MiB | Yes, from `corpus.db` in seconds |
103
+ | `structural_metrics.parquet` | ~0.4 MiB | Only from the object store, via a parallel re-parse of ~3,600 files |
104
+ | `term_coupling.parquet` | ~0.3 MiB | Same |
105
+
106
+ **The corpus cannot be re-fetched into existence.** Registries move underneath
107
+ you: LOV was unreachable through 2026-07 and returned in 2026-08 carrying ~950
108
+ ontologies; Ontohub is a legacy service; download URLs die routinely — 1,263 of
109
+ the recorded attempts already failed at capture time. A crawl run today produces
110
+ a *different* corpus, not this one. These files are the primary record, not a
111
+ cache of something you could rebuild.
112
+
113
+ ### What was removed from the tracked artifacts
114
+
115
+ `corpus.db` and `manifest.jsonl` used to carry the verbatim registry API
116
+ response for every submission, in `source_registry_metadata`. It was 58 MiB —
117
+ 72% of the database — and the same blob was written a second time into the
118
+ manifest, which was 92% this one field. Nothing ever read it.
119
+
120
+ It is now projected to a field allowlist (`ontometer/corpus/metadata.py`):
121
+ `description`, `name`, `acronym`, `hasDomain`, `group`, `hasOntologyLanguage`,
122
+ `hasLicense`, `homepage`, `publication`, `version`, `status`. Dropped are the
123
+ JSON-LD `links` and `@context` boilerplate — 24 MiB of templated URLs — and the
124
+ `contact`, `administeredBy`, `reviews` and `projects` records, which carried the
125
+ names and email addresses of several hundred ontology maintainers. A public
126
+ corpus should not redistribute those, independently of what they weigh. Email
127
+ addresses are redacted from the free-text fields that are kept.
128
+
129
+ The projection is applied at **two** boundaries — when a `FetchRecord` is
130
+ constructed, and again when the manifest is seeded into the database — because a
131
+ manifest captured before the projection existed still holds the full response,
132
+ and seeding from one must not put it back.
133
+
134
+ `parse_error` is clamped to 2,000 characters: two rows accounted for 7.28 of the
135
+ 7.31 MiB it occupied, because an rdflib error echoed an entire unparseable file
136
+ back into its own message.
137
+
138
+ If you need the unprojected metadata, re-fetch it — `canonical_url` and
139
+ `source_id` are recorded per row. `run/compact_corpus.py` applies the projection
140
+ to existing artifacts and is idempotent.
141
+
142
+ ## 4. Getting the raw object store
143
+
144
+ Everything that re-executes parsing needs the raw files.
145
+
146
+ `retrieved_file` is stored **relative** to the object-store root — e.g.
147
+ `objects/61/613f4e44….rdf` — so a corpus captured on one machine resolves on
148
+ another. `file_hash` is the SHA-256 of the raw bytes, so a restored store can be
149
+ verified against the database byte-for-byte rather than taken on trust.
150
+
151
+ ```bash
152
+ export ONTOLOGY_DIR="$HOME/data/Ontometer/ontologies"
153
+ mkdir -p "$ONTOLOGY_DIR"
154
+ tar --zstd -xf ontologies_objects_raw.tar.zst -C "$ONTOLOGY_DIR"
155
+ ```
156
+
157
+ **If you do not have the archive**, the pipeline can re-fetch from
158
+ `canonical_url`, but understand what that gives you: a *new* capture, against
159
+ today's registries, with today's failures. Each OntoPortal instance is
160
+ independently administered and needs its own key (`BIOPORTAL`, `AGROPORTAL`,
161
+ `BIODIVPORTAL`, `MATPORTAL`, `ONTOPORTAL_ASTRO`, `TECHNOPORTAL`); OKG resolution
162
+ uses `GITHUB_TOKEN`.
163
+
164
+ ```bash
165
+ uv run python run/fetch_ontologies.py --sources lov,obo,okg,ontohub --ontoportal all
166
+ ```
167
+
168
+ Rows whose `file_hash` still matches are byte-identical to the original capture;
169
+ rows that differ, or no longer resolve, are the drift.
170
+
171
+ ## 5. Rebuilding
172
+
173
+ ```bash
174
+ uv sync --extra corpus # add --extra research for the notebook
175
+ ```
176
+
177
+ ### Seed → parse → dedup
178
+
179
+ ```bash
180
+ uv run python run/process.py --phases seed,parse,dedup
181
+ ```
182
+
183
+ ```mermaid
184
+ flowchart LR
185
+ A["<b>Phase 0 · seed</b><br/>manifest → registry<br/><i>skips source_ids already present</i>"]
186
+ B["<b>Phase 1 · parse</b><br/>raw files → metadata<br/><i>only rows with parse_status NULL</i>"]
187
+ C["<b>Phase 2 · dedup</b><br/>flag canonical latest<br/><i>only rows not already marked</i>"]
188
+ A --> B --> C
189
+ S(["needs --ontology-dir"]) -.-> B
190
+ ```
191
+
192
+ Each phase is idempotent and resume-safe, which means **against the committed
193
+ `data/corpus.db` this is a no-op** — every phase is gated on work not already
194
+ done. To re-execute the logic rather than replay a stored result, reset first.
195
+
196
+ **Full reset** — rebuild the database from the committed manifest:
197
+
198
+ ```bash
199
+ rm data/corpus.db
200
+ uv run python run/process.py
201
+ ```
202
+
203
+ `data/manifest.jsonl` is independent of `corpus.db` and is not touched by this.
204
+
205
+ **Partial reset** — keep the provenance rows, force Phases 1 and 2 to redo their
206
+ work by clearing the columns each phase gates on:
207
+
208
+ ```bash
209
+ sqlite3 data/corpus.db "
210
+ UPDATE corpus_registry SET
211
+ parse_status = NULL, parse_error = NULL, parse_date = NULL, parse_format = NULL,
212
+ ontology_iri = NULL, ontology_version_iri = NULL, ontology_version_info = NULL,
213
+ triple_count = NULL, class_count = NULL, property_count = NULL, individual_count = NULL,
214
+ imports = NULL, content_hash = NULL,
215
+ referenced_namespaces = NULL, own_namespaces = NULL,
216
+ canonical_entry_id = NULL, is_duplicate = 0, is_latest_version = 0;
217
+ "
218
+ ```
219
+
220
+ Either way the structural sidecars are content-addressed and write-once, keyed
221
+ by `content_hash`: re-parsing a file that hashes to an existing sidecar skips
222
+ the write rather than erroring, so there is no need to clear
223
+ `$ONTOLOGY_DIR/structure/` unless you are validating that logic itself.
224
+
225
+ ### Derived artifacts
226
+
227
+ ```bash
228
+ uv run python -m ontometer.corpus.network.construction # network_{nodes,edges}.parquet
229
+ uv run python run/structural_metrics.py # needs the object store
230
+ uv run python run/term_coupling.py # needs the object store
231
+ ```
232
+
233
+ The network was previously a `.gpickle`; loading a pickle executes whatever is
234
+ inside it, which is not a reasonable thing to ask of anyone who downloads a
235
+ research artifact. The Parquet pair is also about a sixth of the size and
236
+ round-trips the graph exactly.
237
+
238
+ Then open `research/analysis.ipynb`.
239
+
240
+ ## 6. What reproducibility means here
241
+
242
+ Three different claims, worth keeping apart:
243
+
244
+ 1. **The analysis is reproducible from the tracked artifacts alone.** Clone,
245
+ install the `corpus` extra, rebuild the network from `corpus.db`, run the
246
+ notebook. Nothing external is needed. This is the claim that matters for
247
+ checking the results.
248
+ 2. **The parse is reproducible given the object store.** With the raw archive,
249
+ every parse and dedup decision can be re-executed and compared; `file_hash`
250
+ makes the inputs verifiable.
251
+ 3. **The capture is not reproducible.** Re-crawling produces a different corpus,
252
+ because the registries are live services. That is a property of the subject
253
+ matter, not a gap in the tooling — and the drift between captures is itself
254
+ something the temporal work intends to measure.
255
+
256
+ ## Further reading
257
+
258
+ - `ontometer/corpus/pipeline/README.md` — fetch → parse → dedup, phase by phase,
259
+ the streaming strategy for oversized files, and the dedup passes.
260
+ - `ontometer/corpus/network/README.md` — the node and edge model, precisely.
@@ -0,0 +1,29 @@
1
+ """Corpus-scale research: multi-registry fetch, parse, dedup, dependency network.
2
+
3
+ This half of OntoMeter needs a heavier dependency set than the evaluator — HTTP
4
+ scraping, dataframes, Parquet and graph libraries — so it sits behind an extra.
5
+ Importing it without that extra raises a directive message rather than a bare
6
+ ``ModuleNotFoundError`` naming whichever transitive dependency happened to be
7
+ missed first.
8
+
9
+ pip install "ontometer[corpus]"
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ _REQUIRED = ("bs4", "networkx", "pandas", "pyarrow", "requests")
15
+
16
+
17
+ def _check_dependencies() -> None:
18
+ import importlib.util
19
+
20
+ missing = [name for name in _REQUIRED if importlib.util.find_spec(name) is None]
21
+ if missing:
22
+ raise ImportError(
23
+ "ontometer.corpus needs the 'corpus' extra. Install it with:\n"
24
+ ' pip install "ontometer[corpus]"\n'
25
+ f"(missing: {', '.join(missing)})"
26
+ )
27
+
28
+
29
+ _check_dependencies()
@@ -0,0 +1,15 @@
1
+ """
2
+ OntoMeter fetchers package.
3
+
4
+ About:
5
+ Lightweight per-source ontology fetchers. Each module exposes a single
6
+ fetch_<source>() function that returns a list of FetchRecord objects.
7
+ All records share the normalized schema:
8
+ (source_id, canonical_url, retrieved_file, retrieval_date,
9
+ source_registry, source_registry_metadata)
10
+
11
+ Last Updated: 2026-06-24
12
+ Progress: Initial package skeleton.
13
+ Version History:
14
+ - v1.0: Package created as part of per-source fetcher refactor.
15
+ """