hinge 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 (111) hide show
  1. hinge/__init__.py +3 -0
  2. hinge/config/__init__.py +0 -0
  3. hinge/config/logging_setup.py +63 -0
  4. hinge/config/settings.py +23 -0
  5. hinge/config/types.yaml +42 -0
  6. hinge/dbt/.user.yml +1 -0
  7. hinge/dbt/dbt_project.yml +18 -0
  8. hinge/dbt/macros/apply_time_window.sql +11 -0
  9. hinge/dbt/macros/assert_recipe_supported.sql +59 -0
  10. hinge/dbt/macros/build_incidence.sql +46 -0
  11. hinge/dbt/macros/collapse_path.sql +26 -0
  12. hinge/dbt/macros/filter_bots.sql +23 -0
  13. hinge/dbt/macros/network_edges.sql +39 -0
  14. hinge/dbt/macros/project_bipartite.sql +33 -0
  15. hinge/dbt/macros/slice_edges.sql +41 -0
  16. hinge/dbt/models/hin/hin_accounts.sql +14 -0
  17. hinge/dbt/models/hin/hin_artifacts.sql +23 -0
  18. hinge/dbt/models/hin/hin_capabilities.sql +42 -0
  19. hinge/dbt/models/hin/hin_edges.sql +57 -0
  20. hinge/dbt/models/hin/hin_nodes.sql +44 -0
  21. hinge/dbt/models/hin/hin_repositories.sql +15 -0
  22. hinge/dbt/models/intermediate/int_developer_repo_affiliation.sql +27 -0
  23. hinge/dbt/models/intermediate/int_user_artifact_incidence.sql +21 -0
  24. hinge/dbt/models/networks/artifact_reference.sql +27 -0
  25. hinge/dbt/models/networks/co_commit_user_user.sql +48 -0
  26. hinge/dbt/models/networks/co_edit_file_user_user.sql +48 -0
  27. hinge/dbt/models/networks/co_edit_line_user_user.sql +48 -0
  28. hinge/dbt/models/networks/dev_interaction.sql +98 -0
  29. hinge/dbt/models/networks/developer_repo_affiliation.sql +20 -0
  30. hinge/dbt/models/networks/follow_user_user.sql +27 -0
  31. hinge/dbt/models/networks/fork_repo_repo.sql +27 -0
  32. hinge/dbt/models/networks/issue_co_participation.sql +50 -0
  33. hinge/dbt/models/networks/issue_participation.sql +40 -0
  34. hinge/dbt/models/networks/pr_author_reviewer.sql +66 -0
  35. hinge/dbt/models/networks/pr_participation.sql +40 -0
  36. hinge/dbt/models/networks/pr_reviewer_coreview.sql +48 -0
  37. hinge/dbt/models/networks/repo_shared_contributors.sql +41 -0
  38. hinge/dbt/models/networks/star_user_repo.sql +27 -0
  39. hinge/dbt/models/networks/user_mention_user.sql +60 -0
  40. hinge/dbt/models/networks/watch_user_repo.sql +27 -0
  41. hinge/dbt/models/sources/sources.yml +63 -0
  42. hinge/dbt/models/staging/stg_accounts.sql +4 -0
  43. hinge/dbt/models/staging/stg_adapter_manifest.sql +4 -0
  44. hinge/dbt/models/staging/stg_artifacts.sql +4 -0
  45. hinge/dbt/models/staging/stg_relations.sql +4 -0
  46. hinge/dbt/models/staging/stg_repositories.sql +4 -0
  47. hinge/dbt/profiles.yml +9 -0
  48. hinge/dbt/seeds/ref_recipe_requirements.csv +35 -0
  49. hinge/frontends/__init__.py +0 -0
  50. hinge/frontends/cli/__init__.py +3 -0
  51. hinge/frontends/cli/app.py +189 -0
  52. hinge/frontends/lib.py +148 -0
  53. hinge/frontends/tui/__init__.py +7 -0
  54. hinge/frontends/web/__init__.py +6 -0
  55. hinge/kernel/__init__.py +0 -0
  56. hinge/kernel/projection/__init__.py +9 -0
  57. hinge/kernel/projection/projected_graph.py +32 -0
  58. hinge/kernel/projection/projection_spec.py +35 -0
  59. hinge/kernel/protocols/__init__.py +24 -0
  60. hinge/kernel/protocols/dataset_view.py +30 -0
  61. hinge/kernel/protocols/exporter_stage.py +24 -0
  62. hinge/kernel/protocols/projection_stage.py +36 -0
  63. hinge/kernel/protocols/reader_stage.py +52 -0
  64. hinge/kernel/protocols/store_stage.py +71 -0
  65. hinge/kernel/registry.py +71 -0
  66. hinge/kernel/runner.py +236 -0
  67. hinge/kernel/schema/__init__.py +12 -0
  68. hinge/kernel/schema/hin_schema.py +38 -0
  69. hinge/kernel/schema/schema_violation.py +21 -0
  70. hinge/kernel/schema/typed_edge.py +20 -0
  71. hinge/kernel/schema/typed_node.py +17 -0
  72. hinge/stages/__init__.py +0 -0
  73. hinge/stages/exporters/__init__.py +0 -0
  74. hinge/stages/exporters/csv_exporter.py +77 -0
  75. hinge/stages/exporters/dot_exporter.py +13 -0
  76. hinge/stages/exporters/gexf_exporter.py +13 -0
  77. hinge/stages/exporters/gml_exporter.py +79 -0
  78. hinge/stages/exporters/graphml_exporter.py +13 -0
  79. hinge/stages/exporters/jsonld_exporter.py +13 -0
  80. hinge/stages/exporters/networkx_exporter.py +57 -0
  81. hinge/stages/exporters/parquet_exporter.py +13 -0
  82. hinge/stages/projection/__init__.py +0 -0
  83. hinge/stages/projection/dbt_projection.py +342 -0
  84. hinge/stages/projection/specs/__init__.py +0 -0
  85. hinge/stages/projection/specs/artifact_reference.py +14 -0
  86. hinge/stages/projection/specs/co_commit_user_user.py +14 -0
  87. hinge/stages/projection/specs/co_edit_file_user_user.py +14 -0
  88. hinge/stages/projection/specs/co_edit_line_user_user.py +14 -0
  89. hinge/stages/projection/specs/dev_interaction.py +26 -0
  90. hinge/stages/projection/specs/developer_repo_affiliation.py +15 -0
  91. hinge/stages/projection/specs/follow_user_user.py +14 -0
  92. hinge/stages/projection/specs/fork_repo_repo.py +14 -0
  93. hinge/stages/projection/specs/issue_co_participation.py +14 -0
  94. hinge/stages/projection/specs/issue_participation.py +14 -0
  95. hinge/stages/projection/specs/pr_author_reviewer.py +14 -0
  96. hinge/stages/projection/specs/pr_participation.py +14 -0
  97. hinge/stages/projection/specs/pr_reviewer_coreview.py +14 -0
  98. hinge/stages/projection/specs/repo_shared_contributors.py +14 -0
  99. hinge/stages/projection/specs/star_user_repo.py +15 -0
  100. hinge/stages/projection/specs/user_mention_user.py +14 -0
  101. hinge/stages/projection/specs/watch_user_repo.py +14 -0
  102. hinge/stages/readers/__init__.py +0 -0
  103. hinge/stages/readers/_numfocus_contract_adapter.py +314 -0
  104. hinge/stages/readers/numfocus_reader.py +69 -0
  105. hinge/stages/store/__init__.py +0 -0
  106. hinge/stages/store/duckdb_store.py +466 -0
  107. hinge-0.1.0.dist-info/METADATA +309 -0
  108. hinge-0.1.0.dist-info/RECORD +111 -0
  109. hinge-0.1.0.dist-info/WHEEL +4 -0
  110. hinge-0.1.0.dist-info/entry_points.txt +35 -0
  111. hinge-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class ProjectionParams(BaseModel):
9
+ """Free-form key/value parameters passed to a projection at run time.
10
+
11
+ Examples: window start/end dates, minimum interaction count, repo filter.
12
+ Each projection documents which keys it consumes in its SQL header.
13
+ """
14
+
15
+ values: dict[str, Any] = Field(default_factory=dict)
16
+
17
+
18
+ class ProjectionSpec(BaseModel):
19
+ """Declarative description of one projection.
20
+
21
+ A spec is *metadata only*. It points the projection stage at a SQL model
22
+ file and tells callers what the output looks like. Specs are discovered
23
+ by the registry through the ``hinge.projection_specs`` entry-point group;
24
+ third-party packages register new projections the same way readers and
25
+ exporters are registered.
26
+
27
+ See ``hinge/dbt/models/networks/dev_interaction.sql`` for the
28
+ contract every model must satisfy.
29
+ """
30
+
31
+ name: str
32
+ description: str
33
+ model_name: str
34
+ output_node_types: list[str]
35
+ output_edge_types: list[str]
@@ -0,0 +1,24 @@
1
+ from hinge.kernel.protocols.dataset_view import DatasetView
2
+ from hinge.kernel.protocols.exporter_stage import ExporterStage, ExportReceipt
3
+ from hinge.kernel.protocols.projection_stage import EngineFingerprint, ProjectionStage
4
+ from hinge.kernel.protocols.reader_stage import ReaderDescriptor, ReaderStage
5
+ from hinge.kernel.protocols.store_stage import (
6
+ DatasetMeta,
7
+ StoreCensus,
8
+ StoreFingerprint,
9
+ StoreStage,
10
+ )
11
+
12
+ __all__ = [
13
+ "DatasetMeta",
14
+ "DatasetView",
15
+ "EngineFingerprint",
16
+ "ExportReceipt",
17
+ "ExporterStage",
18
+ "ProjectionStage",
19
+ "ReaderDescriptor",
20
+ "ReaderStage",
21
+ "StoreCensus",
22
+ "StoreFingerprint",
23
+ "StoreStage",
24
+ ]
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Protocol
5
+
6
+
7
+ class DatasetView(Protocol):
8
+ """Read-only handle to one dataset inside a store.
9
+
10
+ Stores produce this via :meth:`StoreStage.scope_to_dataset`; projections
11
+ consume it. Today every store backend ships a SQL view of the dataset so
12
+ the projection can read it with whatever query engine fits — the fields
13
+ are intentionally concrete (path + view names) rather than an abstract
14
+ "query handle", because every real projection backend needs either SQL
15
+ or row iteration anyway.
16
+
17
+ A future non-SQL store can implement this by writing its dataset out to
18
+ a temporary DuckDB file before returning — the contract stays one shape.
19
+ """
20
+
21
+ @property
22
+ def dataset_id(self) -> str: ...
23
+ @property
24
+ def db_path(self) -> Path: ...
25
+ @property
26
+ def nodes_view(self) -> str: ...
27
+ @property
28
+ def edges_view(self) -> str: ...
29
+
30
+ def close(self) -> None: ...
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import BinaryIO, Protocol
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from hinge.kernel.projection.projected_graph import ProjectedGraphHandle
8
+
9
+
10
+ class ExportReceipt(BaseModel):
11
+ """Result of one export. ``snapshot_id`` is set by the runner when both
12
+ store and projection fingerprints are available; otherwise it stays None.
13
+ """
14
+
15
+ format: str
16
+ content_hash: str
17
+ schema_version: int
18
+ node_count: int
19
+ edge_count: int
20
+ snapshot_id: str | None = None
21
+
22
+
23
+ class ExporterStage(Protocol):
24
+ def write(self, handle: ProjectedGraphHandle, sink: BinaryIO) -> ExportReceipt: ...
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Protocol
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from hinge.kernel.projection.projected_graph import ProjectedGraphHandle
8
+ from hinge.kernel.projection.projection_spec import ProjectionSpec
9
+ from hinge.kernel.protocols.dataset_view import DatasetView
10
+
11
+
12
+ class EngineFingerprint(BaseModel):
13
+ """Hash of the projection engine's own code (e.g. dbt project files).
14
+ Combined with the store fingerprint and schema version to make snapshot_id.
15
+ """
16
+
17
+ engine: str
18
+ project_hash: str
19
+
20
+
21
+ class ProjectionStage(Protocol):
22
+ """Materialises a typed sub-graph from a dataset.
23
+
24
+ The projection takes a :class:`ProjectionSpec` (what to compute) and a
25
+ :class:`DatasetView` (where to read from). It never touches the store
26
+ directly — the store handed it everything it needs.
27
+ """
28
+
29
+ def run(
30
+ self,
31
+ spec: ProjectionSpec,
32
+ params: dict[str, Any],
33
+ view: DatasetView,
34
+ ) -> ProjectedGraphHandle: ...
35
+
36
+ def fingerprint(self) -> EngineFingerprint: ...
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator
4
+ from pathlib import Path
5
+ from typing import Protocol, runtime_checkable
6
+
7
+ from pydantic import BaseModel
8
+
9
+ from hinge.kernel.protocols.store_stage import StoreStage
10
+ from hinge.kernel.schema.schema_violation import SchemaViolation
11
+ from hinge.kernel.schema.typed_edge import TypedEdge
12
+ from hinge.kernel.schema.typed_node import TypedNode
13
+
14
+
15
+ class ReaderDescriptor(BaseModel):
16
+ dataset: str
17
+ format: str
18
+ path: Path
19
+ byte_size: int
20
+
21
+
22
+ class ReaderStage(Protocol):
23
+ """Reads a dataset file and yields typed graph elements directly.
24
+
25
+ A reader owns both the file-format parsing logic and the domain knowledge
26
+ needed to map raw records to ``TypedNode`` / ``TypedEdge`` objects. It may
27
+ also yield ``SchemaViolation`` instances for records it cannot map — the
28
+ runner counts and logs them rather than aborting the ingest.
29
+ """
30
+
31
+ def iter_elements(self) -> Iterator[TypedNode | TypedEdge | SchemaViolation]: ...
32
+ def describe(self) -> ReaderDescriptor: ...
33
+
34
+
35
+ @runtime_checkable
36
+ class BulkIngestReader(Protocol):
37
+ """Reader that loads a whole dataset in one bulk operation against the store.
38
+
39
+ Implementations push records directly into the store's native engine
40
+ (e.g. DuckDB SQL) and return summary counts. The runner detects this
41
+ shape and uses it in place of the element-by-element ``ReaderStage``
42
+ path — useful when the file format and the store backend let us avoid
43
+ a per-row Python round-trip.
44
+ """
45
+
46
+ def describe(self) -> ReaderDescriptor: ...
47
+ def bulk_ingest(self, store: StoreStage, dataset_id: str) -> tuple[int, int, int]:
48
+ """Load the dataset into ``store`` under ``dataset_id``.
49
+
50
+ Returns ``(records_read, nodes_written, edges_written)``.
51
+ """
52
+ ...
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable, Iterator
4
+ from datetime import datetime
5
+ from types import TracebackType
6
+ from typing import Protocol
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from hinge.kernel.protocols.dataset_view import DatasetView
11
+ from hinge.kernel.schema.typed_edge import TypedEdge
12
+ from hinge.kernel.schema.typed_node import TypedNode
13
+
14
+
15
+ class StoreFingerprint(BaseModel):
16
+ """Content hash of one dataset inside a store. Used to compute snapshot_id."""
17
+
18
+ dataset_id: str
19
+ schema_version: int
20
+ node_count: int
21
+ edge_count: int
22
+ content_hash: str
23
+
24
+
25
+ class StoreCensus(BaseModel):
26
+ """Per-type counts for one dataset."""
27
+
28
+ nodes_by_type: dict[str, int]
29
+ edges_by_type: dict[str, int]
30
+
31
+
32
+ class DatasetMeta(BaseModel):
33
+ dataset_id: str
34
+ reader: str
35
+ path: str
36
+ ingested_at: datetime
37
+ node_count: int | None = None
38
+ edge_count: int | None = None
39
+
40
+
41
+ class StoreStage(Protocol):
42
+ """Persists typed graph elements and surfaces them to projections.
43
+
44
+ The store is a context manager: it acquires whatever resources it needs
45
+ (file lock, connection, transaction) on ``__enter__`` and releases them on
46
+ ``__exit__``. This makes the DuckDB single-writer lock explicit at the
47
+ call site rather than buried inside method calls.
48
+ """
49
+
50
+ def __enter__(self) -> StoreStage: ...
51
+ def __exit__(
52
+ self,
53
+ exc_type: type[BaseException] | None,
54
+ exc: BaseException | None,
55
+ tb: TracebackType | None,
56
+ ) -> None: ...
57
+
58
+ # ---- ingest ----
59
+ def begin_dataset(self, dataset_id: str, reader: str, path: str) -> None: ...
60
+ def finalise_dataset(self, dataset_id: str, node_count: int, edge_count: int) -> None: ...
61
+ def upsert(
62
+ self, dataset_id: str, nodes: Iterable[TypedNode], edges: Iterable[TypedEdge]
63
+ ) -> int: ...
64
+ def discard_dataset(self, dataset_id: str) -> None: ...
65
+
66
+ # ---- read / project ----
67
+ def scope_to_dataset(self, dataset_id: str) -> DatasetView: ...
68
+ def query(self, dataset_id: str, node_types: list[str]) -> Iterator[TypedNode]: ...
69
+ def census(self, dataset_id: str) -> StoreCensus: ...
70
+ def fingerprint(self, dataset_id: str) -> StoreFingerprint: ...
71
+ def list_datasets(self) -> list[DatasetMeta]: ...
@@ -0,0 +1,71 @@
1
+ """Entry-point based registry for stage implementations and projection specs.
2
+
3
+ The kernel never imports a concrete stage class or a built-in spec. At startup
4
+ the registry scans installed packages for the entry-point groups declared in
5
+ ``pyproject.toml`` and exposes them by name. Frontends call ``get_<role>``
6
+ to obtain a stage instance and ``get_projection_spec(name)`` to look up a
7
+ ProjectionSpec — symmetric: every extensible thing in the system plugs in the
8
+ same way.
9
+
10
+ Registered groups:
11
+
12
+ ``hinge.readers`` -> Reader classes
13
+ ``hinge.stores`` -> Store classes
14
+ ``hinge.projections`` -> Projection stage classes (engines)
15
+ ``hinge.projection_specs`` -> ProjectionSpec instances (one per task-specific subgraph)
16
+ ``hinge.exporters`` -> Exporter classes
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from importlib.metadata import entry_points
22
+ from typing import Any
23
+
24
+ from hinge.kernel.projection.projection_spec import ProjectionSpec
25
+
26
+ _GROUPS = {
27
+ "reader": "hinge.readers",
28
+ "store": "hinge.stores",
29
+ "projection": "hinge.projections",
30
+ "projection_spec": "hinge.projection_specs",
31
+ "exporter": "hinge.exporters",
32
+ }
33
+
34
+
35
+ def _load(role: str, name: str) -> Any:
36
+ group = _GROUPS[role]
37
+ matches = [ep for ep in entry_points(group=group) if ep.name == name]
38
+ if not matches:
39
+ available = sorted(ep.name for ep in entry_points(group=group))
40
+ raise LookupError(f"No {role} named {name!r} registered. Available: {available}")
41
+ return matches[0].load()
42
+
43
+
44
+ def get_reader(name: str, **kwargs: Any) -> Any:
45
+ return _load("reader", name)(**kwargs)
46
+
47
+
48
+ def get_store(name: str = "duckdb", **kwargs: Any) -> Any:
49
+ return _load("store", name)(**kwargs)
50
+
51
+
52
+ def get_projection(name: str = "dbt", **kwargs: Any) -> Any:
53
+ return _load("projection", name)(**kwargs)
54
+
55
+
56
+ def get_projection_spec(name: str) -> ProjectionSpec:
57
+ obj = _load("projection_spec", name)
58
+ if not isinstance(obj, ProjectionSpec):
59
+ raise TypeError(
60
+ f"Entry-point hinge.projection_specs:{name} must resolve to a "
61
+ f"ProjectionSpec instance, got {type(obj).__name__}"
62
+ )
63
+ return obj
64
+
65
+
66
+ def get_exporter(name: str, **kwargs: Any) -> Any:
67
+ return _load("exporter", name)(**kwargs)
68
+
69
+
70
+ def list_registered(role: str) -> list[str]:
71
+ return sorted(ep.name for ep in entry_points(group=_GROUPS[role]))
hinge/kernel/runner.py ADDED
@@ -0,0 +1,236 @@
1
+ """Linear pipeline runner.
2
+
3
+ Pipeline shapes supported:
4
+
5
+ Reader → Store (ingest)
6
+ Store → Projection (project)
7
+ Store → Projection → Exporter (export)
8
+
9
+ The runner owns three responsibilities:
10
+
11
+ * batching and schema validation during ingest;
12
+ * transactional bookkeeping — a failed ingest leaves no half-written dataset;
13
+ * snapshot_id assembly during export, computed from store + projection
14
+ fingerprints + the HIN schema version + the tool version.
15
+
16
+ Stage implementations are passed in; the runner never constructs them or
17
+ imports concrete classes.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import logging
24
+ import uuid
25
+ from typing import Any, BinaryIO
26
+
27
+ from pydantic import BaseModel
28
+
29
+ from hinge.kernel.projection.projected_graph import ProjectedGraphHandle
30
+ from hinge.kernel.projection.projection_spec import ProjectionSpec
31
+ from hinge.kernel.protocols.dataset_view import DatasetView
32
+ from hinge.kernel.protocols.exporter_stage import ExporterStage, ExportReceipt
33
+ from hinge.kernel.protocols.projection_stage import ProjectionStage
34
+ from hinge.kernel.protocols.reader_stage import BulkIngestReader, ReaderStage
35
+ from hinge.kernel.protocols.store_stage import StoreStage
36
+ from hinge.kernel.schema.hin_schema import HINSchema
37
+ from hinge.kernel.schema.schema_violation import SchemaViolation
38
+ from hinge.kernel.schema.typed_edge import TypedEdge
39
+ from hinge.kernel.schema.typed_node import TypedNode
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ _TOOL_VERSION = "0.1.0"
44
+ _BATCH_SIZE = 100_000
45
+
46
+
47
+ class IngestReport(BaseModel):
48
+ dataset_id: str
49
+ elements_read: int
50
+ nodes_upserted: int
51
+ edges_upserted: int
52
+ violation_count: int
53
+
54
+
55
+ def run_ingest(
56
+ reader: ReaderStage | BulkIngestReader, store: StoreStage, schema: HINSchema
57
+ ) -> IngestReport:
58
+ """Read a dataset and persist it. Transactional: on any exception the
59
+ half-written dataset is discarded so the store stays consistent.
60
+
61
+ Readers that implement ``bulk_ingest`` are dispatched through the bulk
62
+ path — schema validation is delegated to the reader because the records
63
+ never cross the Python boundary.
64
+ """
65
+ dataset_id = uuid.uuid4().hex
66
+ desc = reader.describe()
67
+ logger.info(
68
+ "ingest started — dataset_id=%s reader=%s format=%s path=%s size=%.1fMB",
69
+ dataset_id,
70
+ desc.dataset,
71
+ desc.format,
72
+ desc.path,
73
+ desc.byte_size / 1_048_576,
74
+ )
75
+
76
+ store.begin_dataset(dataset_id, desc.dataset, str(desc.path))
77
+ if isinstance(reader, BulkIngestReader):
78
+ try:
79
+ records, nodes_total, edges_total = reader.bulk_ingest(store, dataset_id)
80
+ store.finalise_dataset(dataset_id, nodes_total, edges_total)
81
+ except BaseException:
82
+ logger.exception("ingest failed — discarding dataset_id=%s", dataset_id)
83
+ store.discard_dataset(dataset_id)
84
+ raise
85
+ logger.info(
86
+ "ingest complete (bulk) — dataset_id=%s nodes=%d edges=%d",
87
+ dataset_id,
88
+ nodes_total,
89
+ edges_total,
90
+ )
91
+ return IngestReport(
92
+ dataset_id=dataset_id,
93
+ elements_read=records,
94
+ nodes_upserted=nodes_total,
95
+ edges_upserted=edges_total,
96
+ violation_count=0,
97
+ )
98
+
99
+ nodes_total = 0
100
+ edges_total = 0
101
+ violation_count = 0
102
+ elements_read = 0
103
+ node_buf: list[TypedNode] = []
104
+ edge_buf: list[TypedEdge] = []
105
+
106
+ try:
107
+ for element in reader.iter_elements():
108
+ elements_read += 1
109
+ if isinstance(element, SchemaViolation):
110
+ violation_count += 1
111
+ logger.warning(
112
+ "violation [%s] %s:%d — %s",
113
+ element.code,
114
+ element.source,
115
+ element.line,
116
+ element.message,
117
+ )
118
+ continue
119
+ if isinstance(element, TypedNode):
120
+ if not schema.allows_node(element.type):
121
+ violation_count += 1
122
+ logger.warning(
123
+ "violation [invalid_node_type] node id=%s — type %r not in schema",
124
+ element.id,
125
+ element.type,
126
+ )
127
+ continue
128
+ node_buf.append(element)
129
+ elif isinstance(element, TypedEdge):
130
+ if not schema.allows_edge(element.type):
131
+ violation_count += 1
132
+ logger.warning(
133
+ "violation [invalid_edge_type] %s→%s — type %r not in schema",
134
+ element.src_id,
135
+ element.dst_id,
136
+ element.type,
137
+ )
138
+ continue
139
+ edge_buf.append(element)
140
+
141
+ if len(node_buf) + len(edge_buf) >= _BATCH_SIZE:
142
+ store.upsert(dataset_id, node_buf, edge_buf)
143
+ nodes_total += len(node_buf)
144
+ edges_total += len(edge_buf)
145
+ logger.debug(
146
+ "batch upserted — nodes=%d edges=%d (running total nodes=%d edges=%d)",
147
+ len(node_buf),
148
+ len(edge_buf),
149
+ nodes_total,
150
+ edges_total,
151
+ )
152
+ node_buf.clear()
153
+ edge_buf.clear()
154
+
155
+ if node_buf or edge_buf:
156
+ store.upsert(dataset_id, node_buf, edge_buf)
157
+ nodes_total += len(node_buf)
158
+ edges_total += len(edge_buf)
159
+
160
+ store.finalise_dataset(dataset_id, nodes_total, edges_total)
161
+ except BaseException:
162
+ logger.exception("ingest failed — discarding dataset_id=%s", dataset_id)
163
+ store.discard_dataset(dataset_id)
164
+ raise
165
+
166
+ logger.info(
167
+ "ingest complete — dataset_id=%s nodes=%d edges=%d violations=%d",
168
+ dataset_id,
169
+ nodes_total,
170
+ edges_total,
171
+ violation_count,
172
+ )
173
+ return IngestReport(
174
+ dataset_id=dataset_id,
175
+ elements_read=elements_read,
176
+ nodes_upserted=nodes_total,
177
+ edges_upserted=edges_total,
178
+ violation_count=violation_count,
179
+ )
180
+
181
+
182
+ def run_projection(
183
+ projection: ProjectionStage,
184
+ spec: ProjectionSpec,
185
+ view: DatasetView,
186
+ params: dict[str, Any] | None = None,
187
+ ) -> ProjectedGraphHandle:
188
+ logger.info(
189
+ "projection started — spec=%s dataset_id=%s params=%s",
190
+ spec.name,
191
+ view.dataset_id,
192
+ params or {},
193
+ )
194
+ handle = projection.run(spec, params or {}, view)
195
+ logger.info("projection complete — spec=%s dataset_id=%s", spec.name, view.dataset_id)
196
+ return handle
197
+
198
+
199
+ def run_export(
200
+ handle: ProjectedGraphHandle,
201
+ exporter: ExporterStage,
202
+ sink: BinaryIO,
203
+ *,
204
+ store: StoreStage | None = None,
205
+ projection: ProjectionStage | None = None,
206
+ schema: HINSchema | None = None,
207
+ dataset_id: str | None = None,
208
+ ) -> ExportReceipt:
209
+ """Write a projected graph. When store, projection, schema, and dataset_id
210
+ are all provided the receipt carries a snapshot_id; otherwise it doesn't.
211
+ """
212
+ logger.info("export started")
213
+ receipt = exporter.write(handle, sink)
214
+ if schema is not None:
215
+ receipt = receipt.model_copy(update={"schema_version": schema.schema_version})
216
+ if store and projection and schema and dataset_id:
217
+ receipt = receipt.model_copy(
218
+ update={"snapshot_id": _snapshot_id(store, projection, schema, dataset_id)}
219
+ )
220
+ logger.info(
221
+ "export complete — format=%s nodes=%d edges=%d snapshot=%s",
222
+ receipt.format,
223
+ receipt.node_count,
224
+ receipt.edge_count,
225
+ (receipt.snapshot_id or "")[:12] or "—",
226
+ )
227
+ return receipt
228
+
229
+
230
+ def _snapshot_id(
231
+ store: StoreStage, projection: ProjectionStage, schema: HINSchema, dataset_id: str
232
+ ) -> str:
233
+ sf = store.fingerprint(dataset_id)
234
+ ef = projection.fingerprint()
235
+ parts = f"{sf.content_hash}|{ef.project_hash}|{schema.schema_version}|{_TOOL_VERSION}"
236
+ return hashlib.sha256(parts.encode("utf-8")).hexdigest()
@@ -0,0 +1,12 @@
1
+ from hinge.kernel.schema.hin_schema import HINSchema
2
+ from hinge.kernel.schema.schema_violation import SchemaViolation, SchemaViolationError
3
+ from hinge.kernel.schema.typed_edge import TypedEdge
4
+ from hinge.kernel.schema.typed_node import TypedNode
5
+
6
+ __all__ = [
7
+ "HINSchema",
8
+ "SchemaViolation",
9
+ "SchemaViolationError",
10
+ "TypedEdge",
11
+ "TypedNode",
12
+ ]
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import yaml
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class HINSchema(BaseModel):
11
+ """The vocabulary of node and edge labels permitted at ingest.
12
+
13
+ Loaded from ``hinge/config/types.yaml`` — the single source of truth.
14
+ Labels are open strings; the schema simply enumerates which ones are
15
+ accepted. Projections may introduce labels not listed here; they only
16
+ bind ingest-time validation.
17
+ """
18
+
19
+ schema_version: int
20
+ node_types: set[str] = Field(default_factory=set)
21
+ edge_types: set[str] = Field(default_factory=set)
22
+ artifact_subtypes: set[str] = Field(default_factory=set)
23
+
24
+ @classmethod
25
+ def from_yaml(cls, path: Path) -> HINSchema:
26
+ raw: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8"))
27
+ return cls(
28
+ schema_version=int(raw["schema_version"]),
29
+ node_types=set(raw.get("node_types", [])),
30
+ edge_types=set(raw.get("edge_types", [])),
31
+ artifact_subtypes=set(raw.get("artifact_subtypes", [])),
32
+ )
33
+
34
+ def allows_node(self, label: str) -> bool:
35
+ return label in self.node_types
36
+
37
+ def allows_edge(self, label: str) -> bool:
38
+ return label in self.edge_types
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class SchemaViolation(BaseModel):
7
+ code: str
8
+ message: str
9
+ source: str
10
+ line: int
11
+
12
+ def as_exception(self) -> SchemaViolationError:
13
+ return SchemaViolationError(self)
14
+
15
+
16
+ class SchemaViolationError(Exception):
17
+ def __init__(self, violation: SchemaViolation) -> None:
18
+ super().__init__(
19
+ f"[{violation.code}] {violation.source}:{violation.line} — {violation.message}"
20
+ )
21
+ self.violation = violation
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class TypedEdge(BaseModel):
10
+ """A typed graph edge. ``type`` is an open string label — projections may
11
+ introduce new labels (e.g. ``"interacted_with"``) without touching any enum.
12
+ Validated against :class:`HINSchema` only at the ingest boundary; downstream
13
+ stages treat labels as opaque strings.
14
+ """
15
+
16
+ type: str
17
+ src_id: str
18
+ dst_id: str
19
+ timestamp: datetime | None = None
20
+ attrs: dict[str, Any] = Field(default_factory=dict)