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.
- ontometer/__init__.py +42 -0
- ontometer/assessment.py +104 -0
- ontometer/cli/__init__.py +1 -0
- ontometer/cli/_entry.py +19 -0
- ontometer/cli/main.py +172 -0
- ontometer/corpus/README.md +260 -0
- ontometer/corpus/__init__.py +29 -0
- ontometer/corpus/fetchers/__init__.py +15 -0
- ontometer/corpus/fetchers/_base.py +718 -0
- ontometer/corpus/fetchers/lov.py +245 -0
- ontometer/corpus/fetchers/obo.py +162 -0
- ontometer/corpus/fetchers/okg.py +310 -0
- ontometer/corpus/fetchers/ontohub.py +179 -0
- ontometer/corpus/fetchers/ontoportal.py +350 -0
- ontometer/corpus/metadata.py +129 -0
- ontometer/corpus/network/README.md +207 -0
- ontometer/corpus/network/__init__.py +1 -0
- ontometer/corpus/network/construction.py +386 -0
- ontometer/corpus/paths.py +204 -0
- ontometer/corpus/pipeline/README.md +350 -0
- ontometer/corpus/pipeline/__init__.py +30 -0
- ontometer/corpus/pipeline/db.py +594 -0
- ontometer/corpus/pipeline/dedup.py +427 -0
- ontometer/corpus/pipeline/parse.py +2329 -0
- ontometer/evaluation.py +77 -0
- ontometer/metrics/__init__.py +15 -0
- ontometer/metrics/hierarchy.py +146 -0
- ontometer/metrics/oquare.py +237 -0
- ontometer/metrics/seeds.py +81 -0
- ontometer/model/__init__.py +10 -0
- ontometer/model/load.py +121 -0
- ontometer/model/quality_model.yaml +87 -0
- ontometer/model/scales.yaml +45 -0
- ontometer/onto/__init__.py +14 -0
- ontometer/onto/load.py +125 -0
- ontometer/onto/namespaces.py +93 -0
- ontometer/onto/obo.py +149 -0
- ontometer/onto/view.py +259 -0
- ontometer/report/__init__.py +5 -0
- ontometer/report/render.py +179 -0
- ontometer/review/__init__.py +14 -0
- ontometer/review/findings.py +49 -0
- ontometer/review/llm.py +187 -0
- ontometer/review/prompt.py +87 -0
- ontometer/review/run.py +103 -0
- ontometer/settings.py +62 -0
- ontometer-0.1.0.dist-info/METADATA +263 -0
- ontometer-0.1.0.dist-info/RECORD +52 -0
- ontometer-0.1.0.dist-info/WHEEL +4 -0
- ontometer-0.1.0.dist-info/entry_points.txt +2 -0
- ontometer-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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
|
+
]
|
ontometer/assessment.py
ADDED
|
@@ -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."""
|
ontometer/cli/_entry.py
ADDED
|
@@ -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/<hh>/<sha256><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/<hh>/<hash>.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
|
+
"""
|