mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
feat(provenance): close PROV-O compliance gaps and high-stakes trust blockers (closes #825)
Part A - high-stakes trust blockers: - Invalidation tombstones via ProvenanceManager.invalidate() (archive-then-append, never mutates or deletes) instead of hard delete - Hash-chained integrity: sequence_id/previous_checksum chain every entry to its predecessor; new verify_chain() detects wholesale row deletion that a lone per-row checksum cannot - Typed Agent (AgentRecord: agent_type/is_automated) and Activity (ActivityRecord: start/end timing), wired through all 18 *_provenance.py wrappers - Split parent_entity_id into previous_version_id (correction) vs derived_from_id (cross-source derivation), additive alongside the legacy combined field - Downstream/descendant lineage traversal (get_descendants/trace_descendants, reverse BFS) closing the dead direction="downstream" code path in the Explorer's provenance route - Qualified Association+hadRole and Invalidation in export_prov() - New CLI: provenance invalidate|verify-chain|descendants Part B - general PROV-O spec completeness: - Qualified Generation/Usage/Derivation in export_prov() - wasAssociatedWith, actedOnBehalfOf, wasInformedBy relations - Bitemporal fields (valid_from/valid_until/revision_type/supersedes) plus revision_history()/query_recorded_between(), closing the deprecated kg.ProvenanceTracker's "no direct equivalent yet" migration gaps - prov:Bundle/hadMember membership via bundle_id - Configurable base_uri (--base-uri CLI flag), shared by RDFExporter's NamespaceManager and OWLExporter's default ontology_uri so KG/OWL/PROV exports co-resolve under one namespace instead of three hardcoded ones Bugs fixed along the way: - agent_id was a dead field: no track_* method read it from kwargs - track_entities_batch silently absorbed typed kwargs into the metadata blob - compute_checksum() had to exclude entity_id itself: hashing it made track_entity's versioning-archive relabel permanently orphan any entry already chained from the pre-relabel checksum, a false-positive "broken chain" for a legitimate rename - InMemoryStorage.get_chain_head() ignored the committed head whenever the current transaction had staged entries, corrupting the next chain link - several new ProvenanceEntry fields were wired into the dataclass and export_prov() but not into SQLiteStorage's DDL/INSERT/row-mapping; InMemoryStorage masked the gap. Added a permanent round-trip regression test to catch this class of bug for future field additions Flagged, not fixed (separate pre-existing issues, out of scope for #825): - pipeline/pipeline_provenance.py imports a nonexistent module and wraps a Pipeline dataclass with no run() method - most *_provenance.py wrappers' backing classes are themselves missing or incomplete (context_manager, deduplicator, normalizer, etc.) - kg_provenance.py passes entity_type inside metadata={} instead of as a top-level track_entity() kwarg across most of its call sites
This commit is contained in:
@@ -11,6 +11,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- **PROV-O trust blockers and general spec completeness for `ProvenanceManager`** (#825) by @KaifAhmad1
|
||||
- **Invalidation instead of hard delete**: new `ProvenanceManager.invalidate(entity_id, agent_id, reason=None)` tombstones an entry — archives its pre-invalidation state under a stable versioned key, then appends the invalidated entry (`invalidated`, `invalidated_at_time`, `invalidated_by`, `invalidation_reason`) — instead of mutating or deleting it, so an audit can prove a fact existed, was reviewed, and was retracted. `ProvenanceManager.clear()` remains the bulk dev/test store-reset utility it always was; it was not repurposed
|
||||
- **Hash-chained integrity**: every entry now carries `sequence_id`/`previous_checksum`, chaining it to the entry immediately before it in insertion order. New `ProvenanceManager.verify_chain()` walks the chain and reports any break, including a row hard-deleted directly from the underlying table — something a lone per-row SHA-256 checksum can never detect on its own. `compute_checksum()` now also covers `agent_id`/`agent_type`, the lineage-link fields, and the invalidation fields, closing several fields that previously weren't tamper-evident
|
||||
- **Typed Agent/Activity**: `agent_id` was a dead field — no `track_*` method read it from kwargs, so it was always the `"semantica"` default regardless of what callers passed; fixed, and paired with new `AgentRecord(id, agent_type, is_automated)` / `ActivityRecord(id, activity_type, started_at_time, ended_at_time)` dataclasses (pass via `agent=`/`activity=` kwargs) so a human reviewer, an LLM call, and an automated pipeline stage are now distinguishable, and activities carry real start/end timing. Wired through all 18 `*_provenance.py` wrapper modules and `track_entity`/`track_relationship`/`track_chunk`/`track_property_source`
|
||||
- **Versioning vs. derivation split**: new `previous_version_id` ("this corrects a prior version of the same fact") and `derived_from_id` ("this was derived from a different source entity") fields, additive alongside the legacy combined `parent_entity_id` so existing readers are unaffected
|
||||
- **Downstream lineage traversal**: new `get_descendants()`/`trace_descendants()` (reverse BFS in both `InMemoryStorage` and `SQLiteStorage`), closing the gap flagged in `semantica/explorer/routes/provenance.py` where `direction="downstream"` was dead code with no reverse lookup to feed it; the Explorer's `/api/provenance` lineage response now merges both directions
|
||||
- **W3C PROV-O qualified relations**: `export_prov()` now emits `prov:qualifiedAssociation`/`hadRole` (distinguishing "approved by" from "generated by" for sign-off workflows), `qualifiedGeneration`/`Generation`, `qualifiedUsage`/`Usage`, `qualifiedDerivation`/`Derivation`, `qualifiedInvalidation`/`Invalidation`, `wasAssociatedWith` (Activity→Agent), `actedOnBehalfOf` (Agent→Agent delegation), and `wasInformedBy` (Activity→Activity, via a new `informed_by=[...]` kwarg), alongside the existing plain triples
|
||||
- **Bitemporal + Bundle support**: `revision_type`/`supersedes`/`valid_from`/`valid_until` fields (plain caller-supplied passthrough, matching the deprecated `kg.ProvenanceTracker`'s actual contract) plus new `revision_history()` and `query_recorded_between()` methods, closing the two "no direct equivalent yet" rows in `docs/migration/kg-provenance-tracker.md`; `bundle_id` emits `prov:Bundle`/`hadMember` membership triples to partition provenance by source/dataset/ingestion-run
|
||||
- **Configurable, interlinked namespace**: `export_prov(base_uri=...)` / `--base-uri` CLI flag, defaulting to a new `ProvenanceManager.DEFAULT_BASE_URI` (`https://semantica.dev/ns#`) that `RDFExporter`'s `NamespaceManager` and `OWLExporter`'s default `ontology_uri` now both reuse, so KG-exported, OWL-exported, and PROV-exported URIs for the same `entity_id` co-resolve instead of three independently-hardcoded placeholder domains
|
||||
- New CLI commands: `semantica provenance invalidate|verify-chain|descendants`
|
||||
- **Fixed along the way**: `track_entities_batch()` silently absorbed batch-level typed kwargs (`agent_id`, `entity_type`, `activity_id`) into the opaque `metadata` JSON blob instead of forwarding them, so the documented banking example in `docs/guides/provenance.md` never actually worked as written
|
||||
- **Fixed along the way**: `compute_checksum()` had to exclude `entity_id` itself from the hash — `track_entity()`'s versioning archives a prior value by copying it to a new key (`"X"` → `"X:v:<timestamp>"`), and hashing `entity_id` meant that legitimate relabel permanently orphaned any other entry that had already chained its `previous_checksum` from the pre-relabel value, surfacing as a false-positive "broken chain." Archival and invalidation are now always a pure relabel (unchanged checksum/sequence position) followed by a fresh chained append, never an in-place mutation of an already-chained entry
|
||||
- **Fixed along the way**: `InMemoryStorage.get_chain_head()` ignored the already-committed chain head whenever the current transaction had staged any entries, understating the head and corrupting the next append's chain link
|
||||
- **Fixed along the way**: several new `ProvenanceEntry` fields were initially wired into the dataclass and `export_prov()` but not into `SQLiteStorage`'s DDL/INSERT/row-mapping — `InMemoryStorage` stores the dataclass directly so it masked the gap. Added a permanent regression test (`test_all_fields_round_trip_through_sqlite`) asserting every field survives a SQLite round trip, to catch this class of bug for any future field additions
|
||||
- Flagged, not fixed (separate, pre-existing issues independent of #825): `semantica/pipeline/pipeline_provenance.py` imports a nonexistent module and wraps a `Pipeline` dataclass with no `run()` method, so `PipelineWithProvenance` has never worked; most of the 18 wrapper modules' backing classes are themselves missing or incomplete (e.g. `context.context_manager`, `deduplication.deduplicator`, `normalize.normalizer` don't exist; `EmbeddingGenerator` exists but has no `.embed()`); `kg_provenance.py` passes `entity_type` inside its `metadata={}` dict instead of as a top-level `track_entity()` kwarg across most of its ~30 call sites, so it never actually populates the real field
|
||||
- Extensive new test coverage across `tests/provenance/test_manager.py`, `test_schemas.py`, and `test_storage.py` (invalidation, hash-chain verification including a simulated hard-delete-detection case and an interleaved-chaining stress test, agent/activity typing, versioning/derivation split, downstream lineage, qualified export triples, bitemporal methods, Bundle export, and namespace interlinking)
|
||||
|
||||
- **Altair Anzo triplet store backend** (#813) by @KaifAhmad1
|
||||
- Added `AnzoStore` (`semantica/triplet_store/anzo_store.py`), a fourth peer to `BlazegraphStore`/`RDF4JStore`/`JenaStore` speaking plain SPARQL 1.1 over HTTP — no new dependency, since Anzo has no official Python SDK but needs none
|
||||
- The one structural difference from the existing backends: Anzo addresses data by a dataset/graphmart **URI** (`dataset_uri`, required) rather than a short namespace/repository name, so the endpoint path (`<endpoint>/sparql/<store_type>/<url-encoded_dataset_uri>`) percent-encodes it; `store_type` defaults to `"graphmart"` and can be set to `"dataset"`
|
||||
|
||||
@@ -633,12 +633,29 @@ Every `ProvenanceEntry` maps directly to W3C PROV-O terms. If your compliance te
|
||||
| :--- | :--- | :--- |
|
||||
| `prov:Entity` | `entity_id` | The tracked object — entity, chunk, relationship, or property |
|
||||
| `prov:Activity` | `activity_id` | The process that produced it — `"ner_extraction"`, `"bureau_parsing"` |
|
||||
| `prov:Agent` | `agent_id` | Who ran the activity — pipeline name, analyst ID |
|
||||
| `prov:wasDerivedFrom` | `parent_entity_id` | The previous version of this entity — enables version chaining |
|
||||
| `prov:Agent` / `prov:Person` / `prov:SoftwareAgent` / `prov:Organization` | `agent_id`, `agent_type`, `is_automated` | Who — or what — ran the activity, and whether a human was directly accountable |
|
||||
| `prov:qualifiedAssociation` + `prov:hadRole` | `role` | The agent's role for this specific entity — `"generator"` (default), `"approver"`, `"reviewer"` — for sign-off/four-eyes workflows |
|
||||
| `prov:wasDerivedFrom` | `parent_entity_id` (legacy combined field) | The previous version or source of this entity |
|
||||
| — | `previous_version_id` | This entry corrects/replaces a prior version of the *same* fact |
|
||||
| `prov:wasDerivedFrom` | `derived_from_id` | This entry was derived from a *different* source entity |
|
||||
| `prov:used` | `used_entities` | Entity IDs consumed to produce this one |
|
||||
| `prov:generatedAtTime` | `timestamp` | ISO datetime, auto-set to `datetime.utcnow()` at write time |
|
||||
| `prov:qualifiedInvalidation` | `invalidated`, `invalidated_at_time`, `invalidated_by`, `invalidation_reason` | A retraction/correction recorded as a tombstone via `ProvenanceManager.invalidate()`, never a hard delete |
|
||||
| `prov:startedAtTime` / `prov:endedAtTime` | `activity_started_at_time`, `activity_ended_at_time` | Typed Activity timing — pass an `ActivityRecord` via the `activity=` kwarg to set these together with `activity_id` |
|
||||
| `prov:qualifiedGeneration`/`Generation`, `qualifiedUsage`/`Usage`, `qualifiedDerivation`/`Derivation` | (derived from the fields above) | Additive qualified forms of `wasGeneratedBy`/`used`/`wasDerivedFrom`, emitted automatically alongside the plain triples |
|
||||
| `prov:wasAssociatedWith` | (derived from `agent_id`) | Direct Activity→Agent link, distinct from the Entity→Agent `wasAttributedTo` |
|
||||
| `prov:actedOnBehalfOf` | `acted_on_behalf_of` | Agent→Agent delegation — e.g. an automated agent acting on behalf of the human/organization that authorized it |
|
||||
| `prov:wasInformedBy` | `informed_by_activities` (pass as `informed_by=[...]`) | Chains this entry's activity to prior activities it was informed by (e.g. a pipeline stage informed by the stage before it) |
|
||||
| `prov:Bundle` + `prov:hadMember` | `bundle_id` | Groups entries by source/dataset/ingestion-run (membership triples, not true RDF named-graph partitioning) |
|
||||
| — | `valid_from`, `valid_until`, `revision_type`, `supersedes` | Bitemporal fields merged from the deprecated `kg.ProvenanceTracker` — always caller-supplied (never auto-computed), surfaced via `ProvenanceManager.revision_history()`, which falls back to timestamp-based derivation for entries that don't set them explicitly |
|
||||
|
||||
The `checksum` field is not part of the PROV-O standard — it is Semantica's tamper-detection extension. Every entry's SHA-256 is computed from its content fields at write time and can be recomputed at any time to verify the record has not been modified.
|
||||
`previous_version_id` and `derived_from_id` are additive alongside `parent_entity_id` — existing code reading `parent_entity_id` keeps working unchanged, while new code gets the two relations disambiguated.
|
||||
|
||||
The `checksum` field is not part of the PROV-O standard — it is Semantica's tamper-detection extension. Every entry's SHA-256 now also incorporates `previous_checksum` (the prior entry's checksum, by insertion order via `sequence_id`), chaining every entry to the one before it. `ProvenanceManager.verify_chain()` walks the full chain and reports any break — including a row that was hard-deleted from the underlying table, which a lone per-row checksum can't detect on its own.
|
||||
|
||||
Note: the banking example above passes `agent_id="credit_data_service_v2"` to `track_entities_batch()` — this now actually populates the entry's `agent_id` field (previously a bug caused batch-level typed kwargs like `agent_id`/`entity_type`/`activity_id` to be silently absorbed into the opaque `metadata` blob instead).
|
||||
|
||||
`export_prov()` mints entity/agent/activity URIs under `ProvenanceManager.DEFAULT_BASE_URI` (`https://semantica.dev/ns#` by default — the same namespace `RDFExporter`'s `NamespaceManager` uses for its `"semantica"` prefix, so KG-exported and PROV-exported URIs for the same `entity_id` co-resolve) unless overridden via `export_prov(base_uri=...)` or the CLI's `--base-uri` option.
|
||||
|
||||
## Related Guides
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ Every method on `kg.ProvenanceTracker` now emits a `DeprecationWarning` on use,
|
||||
| `track_entity(entity_id, source, metadata)` | `track_entity(entity_id, source, metadata)` | Same call shape. `ProvenanceManager` additionally auto-links each update to its prior version via `parent_entity_id`. |
|
||||
| `get_all_sources(entity_id)` | `get_all_sources(entity_id)` | Field name differs: the `kg` tracker returns each record's time under `"recorded_at"`; `ProvenanceManager` returns `"timestamp"`. |
|
||||
| `clear(entity_id=None)` | `clear()` | `ProvenanceManager.clear()` clears all provenance data; there is no per-entity clear yet. |
|
||||
| `query_recorded_between(start, end)` | *No direct equivalent yet* | Filter the entries returned by `get_lineage()` / `trace_lineage()` client-side in the meantime. |
|
||||
| `revision_history(fact_id)` | *No direct equivalent yet* | `get_lineage(fact_id)["lineage_chain"]` returns the full chain of `ProvenanceEntry` records but not in the same versioned shape. |
|
||||
| `query_recorded_between(start, end)` | `query_recorded_between(start, end)` | Same call shape; filters by `timestamp` (ISO 8601 string comparison) across all tracked entries, not just one entity. |
|
||||
| `revision_history(fact_id)` | `revision_history(fact_id)` | Same call shape and return shape (`version`, `valid_from`, `valid_until`, `recorded_at`, `author`, optional `revision_type`/`supersedes`) — walks the entity's `previous_version_id` chain rather than a flat per-entity dict. |
|
||||
| `export_audit_log(fact_ids, format)` | *No direct equivalent yet* | Build the export from `get_lineage()` output, or serialize `get_statistics()` for a summary view. |
|
||||
|
||||
Methods with no direct equivalent are not planned to be reimplemented on `kg.ProvenanceTracker` — they will need a small adapter in caller code, or a feature request against `ProvenanceManager` if you rely on them heavily.
|
||||
|
||||
+117
-4
@@ -2559,27 +2559,31 @@ def provenance_audit(cli_ctx: CLIContext, since: Optional[str], fmt: str,
|
||||
@provenance.command("export")
|
||||
@click.option("--format", "fmt", type=click.Choice(["turtle", "ntriples", "jsonld"]),
|
||||
default="turtle", show_default=True)
|
||||
@click.option("--base-uri", "base_uri", default=None,
|
||||
help="Namespace URI entities/agents/activities are minted under "
|
||||
"(default: ProvenanceManager.DEFAULT_BASE_URI).")
|
||||
@click.option("--output", default=None, type=click.Path())
|
||||
@click.option("--dry-run", "local_dry", is_flag=True, default=False)
|
||||
@click.pass_obj
|
||||
def provenance_export(cli_ctx: CLIContext, fmt: str, output: Optional[str],
|
||||
local_dry: bool) -> None:
|
||||
def provenance_export(cli_ctx: CLIContext, fmt: str, base_uri: Optional[str],
|
||||
output: Optional[str], local_dry: bool) -> None:
|
||||
"""Export provenance as W3C PROV-O RDF.
|
||||
|
||||
\b
|
||||
Example:
|
||||
semantica provenance export --format turtle --output prov.ttl
|
||||
semantica provenance export --base-uri https://example.org/kg# --output prov.ttl
|
||||
"""
|
||||
cli_ctx = _require_ctx(cli_ctx)
|
||||
|
||||
def _action() -> None:
|
||||
if _is_dry(cli_ctx, local_dry):
|
||||
_dry(cli_ctx, "export provenance", format=fmt, output=output)
|
||||
_dry(cli_ctx, "export provenance", format=fmt, base_uri=base_uri, output=output)
|
||||
return
|
||||
try:
|
||||
from .provenance import ProvenanceManager
|
||||
pm = ProvenanceManager(config=cli_ctx.config.to_dict())
|
||||
data = pm.export_prov(format=fmt)
|
||||
data = pm.export_prov(format=fmt, base_uri=base_uri)
|
||||
except ImportError as exc:
|
||||
raise click.ClickException(f"Provenance module not available: {exc}") from exc
|
||||
if output:
|
||||
@@ -2621,6 +2625,115 @@ def provenance_check(cli_ctx: CLIContext, strict: bool, local_json: bool) -> Non
|
||||
_run_with_error_handling(_action)
|
||||
|
||||
|
||||
@provenance.command("invalidate")
|
||||
@click.argument("entity_id")
|
||||
@click.option("--by", "agent_id", required=True, help="Agent responsible for the invalidation.")
|
||||
@click.option("--reason", default=None, help="Human-readable reason for the invalidation.")
|
||||
@click.option("--json", "local_json", is_flag=True, default=False)
|
||||
@click.option("--dry-run", "local_dry", is_flag=True, default=False)
|
||||
@click.pass_obj
|
||||
def provenance_invalidate(cli_ctx: CLIContext, entity_id: str, agent_id: str,
|
||||
reason: Optional[str], local_json: bool,
|
||||
local_dry: bool) -> None:
|
||||
"""Mark a tracked entity as invalidated (tombstone, not a hard delete).
|
||||
|
||||
\b
|
||||
Example:
|
||||
semantica provenance invalidate entity_alice --by reviewer_jane --reason "Source retracted"
|
||||
"""
|
||||
cli_ctx = _require_ctx(cli_ctx)
|
||||
|
||||
def _action() -> None:
|
||||
if _is_dry(cli_ctx, local_dry):
|
||||
_dry(cli_ctx, "invalidate provenance entity", entity_id=entity_id, by=agent_id, reason=reason)
|
||||
return
|
||||
try:
|
||||
from .provenance import ProvenanceManager
|
||||
pm = ProvenanceManager(config=cli_ctx.config.to_dict())
|
||||
result = pm.invalidate(entity_id, agent_id=agent_id, reason=reason)
|
||||
except ImportError as exc:
|
||||
raise click.ClickException(f"Provenance module not available: {exc}") from exc
|
||||
except ValueError as exc:
|
||||
raise click.ClickException(str(exc)) from exc
|
||||
if _is_json(cli_ctx, local_json):
|
||||
_jecho(result.to_dict())
|
||||
else:
|
||||
_ok(cli_ctx, f"Invalidated {entity_id} (by {agent_id})")
|
||||
|
||||
_run_with_error_handling(_action)
|
||||
|
||||
|
||||
@provenance.command("verify-chain")
|
||||
@click.option("--json", "local_json", is_flag=True, default=False)
|
||||
@click.pass_obj
|
||||
def provenance_verify_chain(cli_ctx: CLIContext, local_json: bool) -> None:
|
||||
"""Verify the hash chain across all provenance entries.
|
||||
|
||||
Detects wholesale row deletion: per-row checksums alone only prove a
|
||||
surviving row wasn't edited in place, not that no row is missing.
|
||||
|
||||
\b
|
||||
Example:
|
||||
semantica provenance verify-chain
|
||||
"""
|
||||
cli_ctx = _require_ctx(cli_ctx)
|
||||
|
||||
def _action() -> None:
|
||||
try:
|
||||
from .provenance import ProvenanceManager
|
||||
pm = ProvenanceManager(config=cli_ctx.config.to_dict())
|
||||
result = pm.verify_chain()
|
||||
except ImportError as exc:
|
||||
raise click.ClickException(f"Provenance module not available: {exc}") from exc
|
||||
if _is_json(cli_ctx, local_json):
|
||||
_jecho(result)
|
||||
elif result.get("valid"):
|
||||
_ok(cli_ctx, f"Chain verified: {result.get('total_entries')} entries, no breaks")
|
||||
else:
|
||||
_warn(cli_ctx, f"Chain verification failed: {len(result.get('broken_links', []))} broken link(s)")
|
||||
for link in result.get("broken_links", []):
|
||||
click.echo(f" {link}")
|
||||
|
||||
_run_with_error_handling(_action)
|
||||
|
||||
|
||||
@provenance.command("descendants")
|
||||
@click.argument("entity_id")
|
||||
@click.option("--depth", default=None, type=int, show_default=True)
|
||||
@click.option("--json", "local_json", is_flag=True, default=False)
|
||||
@click.pass_obj
|
||||
def provenance_descendants(cli_ctx: CLIContext, entity_id: str, depth: Optional[int],
|
||||
local_json: bool) -> None:
|
||||
"""Show downstream descendants (reverse lineage) for an entity.
|
||||
|
||||
The counterpart to `provenance lineage`, which only traces upstream
|
||||
ancestors. Answers "entity X was wrong — what downstream facts used it?"
|
||||
|
||||
\b
|
||||
Example:
|
||||
semantica provenance descendants entity_alice --depth 3
|
||||
"""
|
||||
cli_ctx = _require_ctx(cli_ctx)
|
||||
|
||||
def _action() -> None:
|
||||
try:
|
||||
from .provenance import ProvenanceManager
|
||||
pm = ProvenanceManager(config=cli_ctx.config.to_dict())
|
||||
if depth is not None:
|
||||
entries = [e.to_dict() for e in pm.trace_descendants(entity_id, max_depth=depth)]
|
||||
result = {"entity_id": entity_id, "depth": depth, "entries": entries}
|
||||
else:
|
||||
result = pm.get_descendants(entity_id) or {"entity_id": entity_id, "entries": []}
|
||||
except ImportError as exc:
|
||||
raise click.ClickException(f"Provenance module not available: {exc}") from exc
|
||||
if _is_json(cli_ctx, local_json):
|
||||
_jecho(result)
|
||||
else:
|
||||
_pprint(cli_ctx, result)
|
||||
|
||||
_run_with_error_handling(_action)
|
||||
|
||||
|
||||
@main.group(invoke_without_command=True)
|
||||
@click.pass_context
|
||||
def validate(ctx: click.Context) -> None:
|
||||
|
||||
@@ -15,15 +15,24 @@ License: MIT
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class SourceTrackerWithUnifiedBackend:
|
||||
"""SourceTracker using unified provenance backend."""
|
||||
|
||||
def __init__(self, **config):
|
||||
def __init__(
|
||||
self,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**config,
|
||||
):
|
||||
"""Initialize with unified backend or fallback to legacy."""
|
||||
from .source_tracker import SourceTracker
|
||||
|
||||
self._agent_id = agent_id or self.__class__.__name__
|
||||
self._is_automated = is_automated
|
||||
|
||||
try:
|
||||
from semantica.provenance import ProvenanceManager
|
||||
self._unified_manager = ProvenanceManager()
|
||||
@@ -35,6 +44,7 @@ class SourceTrackerWithUnifiedBackend:
|
||||
|
||||
def track_property_source(self, entity_id: str, property_name: str, value: Any, source: Any, **metadata):
|
||||
"""Track property source with unified backend."""
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
if self._use_unified:
|
||||
from semantica.provenance import SourceReference
|
||||
|
||||
@@ -45,6 +55,12 @@ class SourceTrackerWithUnifiedBackend:
|
||||
confidence=getattr(source, 'confidence', 1.0)
|
||||
)
|
||||
|
||||
metadata.setdefault("agent_id", self._agent_id)
|
||||
metadata.setdefault("agent_type", "software_agent")
|
||||
metadata.setdefault("is_automated", self._is_automated)
|
||||
metadata.setdefault("activity_started_at_time", activity_started_at_time)
|
||||
metadata.setdefault("activity_ended_at_time", datetime.utcnow().isoformat())
|
||||
|
||||
self._unified_manager.track_property_source(
|
||||
entity_id=entity_id,
|
||||
property_name=property_name,
|
||||
|
||||
@@ -12,19 +12,28 @@ License: MIT
|
||||
"""
|
||||
|
||||
from typing import Optional, Any
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
class ContextManagerWithProvenance:
|
||||
"""Context manager with provenance tracking."""
|
||||
|
||||
def __init__(self, provenance: bool = False, **config):
|
||||
def __init__(
|
||||
self,
|
||||
provenance: bool = False,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**config,
|
||||
):
|
||||
"""Initialize context manager with optional provenance."""
|
||||
from .context_manager import ContextManager
|
||||
|
||||
self.provenance = provenance
|
||||
self._context_manager = ContextManager(**config)
|
||||
self._prov_manager = None
|
||||
self._agent_id = agent_id or self.__class__.__name__
|
||||
self._is_automated = is_automated
|
||||
|
||||
if provenance:
|
||||
try:
|
||||
@@ -35,13 +44,20 @@ class ContextManagerWithProvenance:
|
||||
|
||||
def add_context(self, context: Any, source: Optional[str] = None, **kwargs):
|
||||
"""Add context with provenance tracking."""
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
result = self._context_manager.add_context(context, **kwargs)
|
||||
activity_ended_at_time = datetime.utcnow().isoformat()
|
||||
|
||||
if self.provenance and self._prov_manager:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=f"context_{uuid.uuid4().hex[:8]}",
|
||||
source=source or "context_manager",
|
||||
entity_type="context",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
metadata={"context_preview": str(context)[:100]}
|
||||
)
|
||||
|
||||
|
||||
@@ -13,19 +13,28 @@ Author: Semantica Contributors
|
||||
License: MIT
|
||||
"""
|
||||
|
||||
from typing import List, Any
|
||||
from typing import List, Any, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
class DeduplicatorWithProvenance:
|
||||
"""Deduplicator with provenance tracking."""
|
||||
|
||||
def __init__(self, provenance: bool = False, **config):
|
||||
def __init__(
|
||||
self,
|
||||
provenance: bool = False,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**config,
|
||||
):
|
||||
from .deduplicator import Deduplicator
|
||||
|
||||
self.provenance = provenance
|
||||
self._deduplicator = Deduplicator(**config)
|
||||
self._prov_manager = None
|
||||
self._agent_id = agent_id or self.__class__.__name__
|
||||
self._is_automated = is_automated
|
||||
|
||||
if provenance:
|
||||
try:
|
||||
@@ -36,7 +45,9 @@ class DeduplicatorWithProvenance:
|
||||
|
||||
def deduplicate(self, items: List[Any], source: str = None, **kwargs):
|
||||
"""Deduplicate items with provenance tracking."""
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
unique_items = self._deduplicator.deduplicate(items, **kwargs)
|
||||
activity_ended_at_time = datetime.utcnow().isoformat()
|
||||
|
||||
if self.provenance and self._prov_manager:
|
||||
duplicates_found = len(items) - len(unique_items)
|
||||
@@ -44,6 +55,11 @@ class DeduplicatorWithProvenance:
|
||||
entity_id=f"dedup_{uuid.uuid4().hex[:8]}",
|
||||
source=source or "deduplication",
|
||||
entity_type="deduplication_operation",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
metadata={
|
||||
"input_count": len(items),
|
||||
"output_count": len(unique_items),
|
||||
|
||||
@@ -13,19 +13,28 @@ Author: Semantica Contributors
|
||||
License: MIT
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
class EmbeddingGeneratorWithProvenance:
|
||||
"""Embedding generator with provenance tracking."""
|
||||
|
||||
def __init__(self, provenance: bool = False, **config):
|
||||
def __init__(
|
||||
self,
|
||||
provenance: bool = False,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**config,
|
||||
):
|
||||
from .embedding_generator import EmbeddingGenerator
|
||||
|
||||
self.provenance = provenance
|
||||
self._generator = EmbeddingGenerator(**config)
|
||||
self._prov_manager = None
|
||||
self._agent_id = agent_id or self.__class__.__name__
|
||||
self._is_automated = is_automated
|
||||
|
||||
if provenance:
|
||||
try:
|
||||
@@ -36,13 +45,20 @@ class EmbeddingGeneratorWithProvenance:
|
||||
|
||||
def embed(self, texts: List[str], source: str = None, **kwargs):
|
||||
"""Generate embeddings with provenance tracking."""
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
embeddings = self._generator.embed(texts, **kwargs)
|
||||
activity_ended_at_time = datetime.utcnow().isoformat()
|
||||
|
||||
if self.provenance and self._prov_manager:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=f"embed_{uuid.uuid4().hex[:8]}",
|
||||
source=source or "embedding_generation",
|
||||
entity_type="embeddings",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
metadata={
|
||||
"model": getattr(self._generator, 'model', 'unknown'),
|
||||
"dimensions": len(embeddings[0]) if embeddings else 0,
|
||||
|
||||
@@ -32,21 +32,7 @@ def _classify_prov(node_type: str) -> tuple[str, str]:
|
||||
return "Entity", "group_entity"
|
||||
|
||||
|
||||
def _transform_audit_lineage(lineage: Dict[str, Any], node_id: str) -> Dict[str, Any]:
|
||||
# Mapping decision (W3C PROV-O to frontend swim-lanes):
|
||||
# Every ProvenanceEntry maps to a node classified by _classify_prov(entry["entity_type"]),
|
||||
# placing documents/chunks/entities in 'group_entity' (prov_type='Entity'), persons/systems
|
||||
# in 'group_agent', and actions/processes in 'group_activity'.
|
||||
# For derivation relationships (parent_entity_id and used_entities), we connect
|
||||
# parent -> child directly with edge label set to activity_id (or 'wasDerivedFrom'),
|
||||
# keeping the lineage graph scannable without cluttering it with intermediate activity
|
||||
# nodes when activity_id is an operational label.
|
||||
nodes: List[Dict[str, Any]] = []
|
||||
edges: List[Dict[str, Any]] = []
|
||||
seen_nodes = set()
|
||||
seen_edges = set()
|
||||
|
||||
chain = lineage.get("lineage_chain") or lineage.get("entries") or []
|
||||
def _add_chain_nodes(chain: List[Any], nodes: List[Dict[str, Any]], seen_nodes: set) -> None:
|
||||
for entry in chain:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
@@ -69,6 +55,10 @@ def _transform_audit_lineage(lineage: Dict[str, Any], node_id: str) -> Dict[str,
|
||||
"checksum": entry.get("checksum") or None,
|
||||
})
|
||||
|
||||
|
||||
def _add_chain_edges(
|
||||
chain: List[Any], edges: List[Dict[str, Any]], seen_edges: set, direction: str
|
||||
) -> None:
|
||||
for entry in chain:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
@@ -86,21 +76,11 @@ def _transform_audit_lineage(lineage: Dict[str, Any], node_id: str) -> Dict[str,
|
||||
|
||||
activity = str(entry.get("activity_id") or "wasDerivedFrom")
|
||||
for src in parents:
|
||||
edge_key = (src, eid)
|
||||
edge_key = (src, eid, direction)
|
||||
if edge_key in seen_edges:
|
||||
continue
|
||||
seen_edges.add(edge_key)
|
||||
|
||||
# NOTE: ProvenanceManager.get_lineage() currently only traces upstream
|
||||
# ancestor chains via parent_entity_id and used_entities. It does not perform
|
||||
# reverse lookups for downstream descendants. Consequently, 'direction = "downstream"'
|
||||
# is unreachable in practice for this audit path until reverse lookup is supported
|
||||
# by ProvenanceManager. All ancestor derivation edges are upstream lineage.
|
||||
if src == node_id:
|
||||
direction = "downstream"
|
||||
else:
|
||||
direction = "upstream"
|
||||
|
||||
edges.append({
|
||||
"id": f"{src}-{eid}",
|
||||
"source": src,
|
||||
@@ -109,6 +89,44 @@ def _transform_audit_lineage(lineage: Dict[str, Any], node_id: str) -> Dict[str,
|
||||
"direction": direction,
|
||||
})
|
||||
|
||||
|
||||
def _transform_audit_lineage(
|
||||
lineage: Dict[str, Any],
|
||||
node_id: str,
|
||||
descendants: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
# Mapping decision (W3C PROV-O to frontend swim-lanes):
|
||||
# Every ProvenanceEntry maps to a node classified by _classify_prov(entry["entity_type"]),
|
||||
# placing documents/chunks/entities in 'group_entity' (prov_type='Entity'), persons/systems
|
||||
# in 'group_agent', and actions/processes in 'group_activity'.
|
||||
# For derivation relationships (parent_entity_id and used_entities), we connect
|
||||
# parent -> child directly with edge label set to activity_id (or 'wasDerivedFrom'),
|
||||
# keeping the lineage graph scannable without cluttering it with intermediate activity
|
||||
# nodes when activity_id is an operational label.
|
||||
#
|
||||
# Upstream edges come from lineage's ancestor chain (parent_entity_id/
|
||||
# used_entities, via ProvenanceManager.get_lineage()); downstream edges
|
||||
# come from ProvenanceManager.get_descendants()'s descendant chain (issue
|
||||
# #825, Part A item 5). Previously 'direction="downstream"' was dead code
|
||||
# here since no reverse lookup existed.
|
||||
nodes: List[Dict[str, Any]] = []
|
||||
edges: List[Dict[str, Any]] = []
|
||||
seen_nodes: set = set()
|
||||
seen_edges: set = set()
|
||||
|
||||
ancestor_chain = lineage.get("lineage_chain") or lineage.get("entries") or []
|
||||
descendant_chain = (
|
||||
(descendants or {}).get("descendant_chain")
|
||||
or (descendants or {}).get("entries")
|
||||
or []
|
||||
)
|
||||
|
||||
_add_chain_nodes(ancestor_chain, nodes, seen_nodes)
|
||||
_add_chain_nodes(descendant_chain, nodes, seen_nodes)
|
||||
|
||||
_add_chain_edges(ancestor_chain, edges, seen_edges, "upstream")
|
||||
_add_chain_edges(descendant_chain, edges, seen_edges, "downstream")
|
||||
|
||||
for edge in edges:
|
||||
for endpoint in (edge["source"], edge["target"]):
|
||||
if endpoint not in seen_nodes:
|
||||
@@ -132,9 +150,9 @@ def _transform_audit_lineage(lineage: Dict[str, Any], node_id: str) -> Dict[str,
|
||||
def _build_provenance(session: GraphSession, node_id: Optional[str] = None) -> dict:
|
||||
"""Build provenance lineage for a node, attempting the audit-grade store first.
|
||||
|
||||
NOTE: The audit path (source='audit') shows verified upstream lineage only.
|
||||
For descendant/downstream relationships, the naive graph-traversal fallback
|
||||
remains the only source until ProvenanceManager gains a reverse lookup.
|
||||
Combines ProvenanceManager.get_lineage() (upstream ancestors) with
|
||||
get_descendants() (downstream — issue #825, Part A item 5) so both
|
||||
directions are populated from the audit-grade store, not just upstream.
|
||||
"""
|
||||
if not node_id:
|
||||
return {"nodes": [], "edges": [], "source": "graph_traversal"}
|
||||
@@ -149,7 +167,14 @@ def _build_provenance(session: GraphSession, node_id: Optional[str] = None) -> d
|
||||
entries = lineage.get("lineage_chain") or lineage.get("entries") or []
|
||||
integrity_ok = all(verify_checksum(entry) for entry in entries)
|
||||
if integrity_ok:
|
||||
return _transform_audit_lineage(lineage, node_id)
|
||||
descendants = {}
|
||||
try:
|
||||
descendants = manager.get_descendants(node_id) or {}
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"ProvenanceManager get_descendants failed for {node_id}: {exc}"
|
||||
)
|
||||
return _transform_audit_lineage(lineage, node_id, descendants)
|
||||
logger.warning(
|
||||
f"Provenance integrity verification failed for {node_id}, falling back to graph traversal"
|
||||
)
|
||||
|
||||
@@ -13,19 +13,28 @@ Author: Semantica Contributors
|
||||
License: MIT
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
class ExporterWithProvenance:
|
||||
"""Base exporter with provenance tracking."""
|
||||
|
||||
def __init__(self, provenance: bool = False, **config):
|
||||
def __init__(
|
||||
self,
|
||||
provenance: bool = False,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**config,
|
||||
):
|
||||
from .exporter import Exporter
|
||||
|
||||
self.provenance = provenance
|
||||
self._exporter = Exporter(**config)
|
||||
self._prov_manager = None
|
||||
self._agent_id = agent_id or self.__class__.__name__
|
||||
self._is_automated = is_automated
|
||||
|
||||
if provenance:
|
||||
try:
|
||||
@@ -36,13 +45,20 @@ class ExporterWithProvenance:
|
||||
|
||||
def export(self, data: Any, destination: str, **kwargs):
|
||||
"""Export data with provenance tracking."""
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
result = self._exporter.export(data, destination, **kwargs)
|
||||
activity_ended_at_time = datetime.utcnow().isoformat()
|
||||
|
||||
if self.provenance and self._prov_manager:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=f"export_{uuid.uuid4().hex[:8]}",
|
||||
source="export_operation",
|
||||
entity_type="export",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
metadata={
|
||||
"destination": destination,
|
||||
"format": kwargs.get('format', 'unknown')
|
||||
|
||||
@@ -31,6 +31,12 @@ from ..utils.helpers import ensure_directory
|
||||
from ..utils.logging import get_logger
|
||||
from ..utils.progress_tracker import get_progress_tracker
|
||||
|
||||
# Issue #825, Part B Tier 3 — exporter interlinking. Reuses the same default
|
||||
# namespace as ProvenanceManager.export_prov() and RDFExporter's
|
||||
# NamespaceManager "semantica" entry, so ontology URIs, KG instance URIs, and
|
||||
# PROV-exported URIs co-resolve under one shared namespace by default.
|
||||
from ..provenance.manager import DEFAULT_BASE_URI
|
||||
|
||||
|
||||
class OWLExporter:
|
||||
"""
|
||||
@@ -58,7 +64,7 @@ class OWLExporter:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ontology_uri: str = "https://semantica.dev/ontology/",
|
||||
ontology_uri: str = DEFAULT_BASE_URI,
|
||||
version: str = "1.0",
|
||||
format: str = "owl-xml",
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
@@ -70,7 +76,8 @@ class OWLExporter:
|
||||
Sets up the exporter with ontology URI, version, and format configuration.
|
||||
|
||||
Args:
|
||||
ontology_uri: Base URI for the ontology (default: "https://semantica.dev/ontology/")
|
||||
ontology_uri: Base URI for the ontology (default: ProvenanceManager.DEFAULT_BASE_URI,
|
||||
shared with RDFExporter's NamespaceManager and export_prov() so URIs co-resolve)
|
||||
version: Ontology version string (default: "1.0")
|
||||
format: Default export format - 'owl-xml' or 'turtle' (default: 'owl-xml')
|
||||
config: Optional configuration dictionary (merged with kwargs)
|
||||
|
||||
@@ -71,13 +71,19 @@ class NamespaceManager:
|
||||
"""
|
||||
self.logger = get_logger("namespace_manager")
|
||||
|
||||
# Standard RDF namespaces
|
||||
# Standard RDF namespaces. "semantica" reuses ProvenanceManager's
|
||||
# DEFAULT_BASE_URI (issue #825, Part B Tier 3 — exporter
|
||||
# interlinking) so KG-exported and PROV-exported URIs for the same
|
||||
# entity_id co-resolve to the same namespace instead of two
|
||||
# independently-hardcoded placeholder domains.
|
||||
from ..provenance.manager import DEFAULT_BASE_URI
|
||||
|
||||
self.namespaces: Dict[str, str] = {
|
||||
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
|
||||
"rdfs": "http://www.w3.org/2000/01/rdf-schema#",
|
||||
"owl": "http://www.w3.org/2002/07/owl#",
|
||||
"xsd": "http://www.w3.org/2001/XMLSchema#",
|
||||
"semantica": "https://semantica.dev/ns#",
|
||||
"semantica": DEFAULT_BASE_URI,
|
||||
}
|
||||
self.config = config or {}
|
||||
|
||||
|
||||
@@ -13,19 +13,28 @@ Author: Semantica Contributors
|
||||
License: MIT
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
class GraphStoreWithProvenance:
|
||||
"""Graph store with provenance tracking."""
|
||||
|
||||
def __init__(self, provenance: bool = False, **config):
|
||||
def __init__(
|
||||
self,
|
||||
provenance: bool = False,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**config,
|
||||
):
|
||||
from .graph_store import GraphStore
|
||||
|
||||
self.provenance = provenance
|
||||
self._store = GraphStore(**config)
|
||||
self._prov_manager = None
|
||||
self._agent_id = agent_id or self.__class__.__name__
|
||||
self._is_automated = is_automated
|
||||
|
||||
if provenance:
|
||||
try:
|
||||
@@ -36,7 +45,9 @@ class GraphStoreWithProvenance:
|
||||
|
||||
def add_node(self, node: Any, source: str = None, **kwargs):
|
||||
"""Add node with provenance tracking."""
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
result = self._store.add_node(node, **kwargs)
|
||||
activity_ended_at_time = datetime.utcnow().isoformat()
|
||||
|
||||
if self.provenance and self._prov_manager:
|
||||
node_id = getattr(node, 'id', f"node_{uuid.uuid4().hex[:8]}")
|
||||
@@ -44,6 +55,11 @@ class GraphStoreWithProvenance:
|
||||
entity_id=node_id,
|
||||
source=source or "graph_store",
|
||||
entity_type="graph_node",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
metadata={"properties": getattr(node, 'properties', {})}
|
||||
)
|
||||
|
||||
|
||||
@@ -14,15 +14,24 @@ License: MIT
|
||||
"""
|
||||
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
class IngestProvenanceMixin:
|
||||
"""Mixin for ingest provenance tracking."""
|
||||
|
||||
def __init__(self, provenance: bool = False, **kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
provenance: bool = False,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**kwargs,
|
||||
):
|
||||
self.provenance = provenance
|
||||
self._prov_manager = None
|
||||
self._agent_id = agent_id or self.__class__.__name__
|
||||
self._is_automated = is_automated
|
||||
|
||||
if provenance:
|
||||
try:
|
||||
@@ -35,15 +44,25 @@ class IngestProvenanceMixin:
|
||||
class PDFIngestorWithProvenance(IngestProvenanceMixin):
|
||||
"""PDF ingestor with provenance tracking."""
|
||||
|
||||
def __init__(self, provenance: bool = False, **config):
|
||||
def __init__(
|
||||
self,
|
||||
provenance: bool = False,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**config,
|
||||
):
|
||||
from .pdf_ingestor import PDFIngestor
|
||||
|
||||
IngestProvenanceMixin.__init__(self, provenance=provenance)
|
||||
IngestProvenanceMixin.__init__(
|
||||
self, provenance=provenance, agent_id=agent_id, is_automated=is_automated
|
||||
)
|
||||
self._ingestor = PDFIngestor(**config)
|
||||
|
||||
def ingest(self, file_path: str, **kwargs):
|
||||
"""Ingest PDF with provenance tracking."""
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
docs = self._ingestor.ingest(file_path, **kwargs)
|
||||
activity_ended_at_time = datetime.utcnow().isoformat()
|
||||
|
||||
if self.provenance and self._prov_manager:
|
||||
for doc in docs:
|
||||
@@ -52,6 +71,11 @@ class PDFIngestorWithProvenance(IngestProvenanceMixin):
|
||||
entity_id=doc_id,
|
||||
source=file_path,
|
||||
entity_type="document",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
metadata={
|
||||
"file_type": "pdf",
|
||||
"pages": getattr(doc, 'page_count', None)
|
||||
|
||||
@@ -54,6 +54,7 @@ Version: 1.0.0
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
import time
|
||||
|
||||
@@ -78,12 +79,20 @@ class GraphBuilderWithProvenance:
|
||||
})
|
||||
"""
|
||||
|
||||
def __init__(self, provenance: bool = False, **config):
|
||||
def __init__(
|
||||
self,
|
||||
provenance: bool = False,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**config,
|
||||
):
|
||||
from .graph_builder import GraphBuilder
|
||||
|
||||
self.provenance = provenance
|
||||
self._builder = GraphBuilder(**config)
|
||||
self._prov_manager = None
|
||||
self._agent_id = agent_id or self.__class__.__name__
|
||||
self._is_automated = is_automated
|
||||
|
||||
if provenance:
|
||||
try:
|
||||
@@ -94,12 +103,18 @@ class GraphBuilderWithProvenance:
|
||||
|
||||
def build(self, sources, **kwargs):
|
||||
"""Build graph with provenance tracking."""
|
||||
# Track the build operation
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
# Track the build operation (recorded before the build runs, so it
|
||||
# has no end time yet — this is the "in progress" marker).
|
||||
if self.provenance and self._prov_manager:
|
||||
build_id = f"graph_build_{uuid.uuid4().hex[:8]}"
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=build_id,
|
||||
source="graph_construction",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
metadata={
|
||||
"entity_type": "graph_build_operation",
|
||||
"operation": "build_graph",
|
||||
@@ -109,6 +124,7 @@ class GraphBuilderWithProvenance:
|
||||
)
|
||||
|
||||
result = self._builder.build(sources, **kwargs)
|
||||
activity_ended_at_time = datetime.utcnow().isoformat()
|
||||
|
||||
# Track individual entities and relationships if available
|
||||
if self.provenance and self._prov_manager and hasattr(result, 'get'):
|
||||
@@ -124,6 +140,11 @@ class GraphBuilderWithProvenance:
|
||||
entity_id=entity_id,
|
||||
source="graph_construction",
|
||||
entity_type="graph_entity",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
metadata={
|
||||
"operation": "build_entity",
|
||||
"entity_type": entity.get('type'),
|
||||
@@ -139,6 +160,11 @@ class GraphBuilderWithProvenance:
|
||||
entity_id=rel_id,
|
||||
source="graph_construction",
|
||||
entity_type="graph_relationship",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
metadata={
|
||||
"operation": "build_relationship",
|
||||
"relationship_type": relationship.get('type'),
|
||||
@@ -154,12 +180,18 @@ class GraphBuilderWithProvenance:
|
||||
|
||||
def build_single_source(self, kg_data, **kwargs):
|
||||
"""Build graph from single source with provenance tracking."""
|
||||
# Track the build operation
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
# Track the build operation (recorded before the build runs, so it
|
||||
# has no end time yet — this is the "in progress" marker).
|
||||
if self.provenance and self._prov_manager:
|
||||
build_id = f"graph_build_single_{uuid.uuid4().hex[:8]}"
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=build_id,
|
||||
source="graph_construction",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
metadata={
|
||||
"entity_type": "graph_build_operation",
|
||||
"operation": "build_single_source",
|
||||
@@ -168,6 +200,7 @@ class GraphBuilderWithProvenance:
|
||||
)
|
||||
|
||||
result = self._builder.build_single_source(kg_data, **kwargs)
|
||||
activity_ended_at_time = datetime.utcnow().isoformat()
|
||||
|
||||
# Track entities and relationships if available
|
||||
if self.provenance and self._prov_manager and isinstance(result, dict):
|
||||
@@ -182,6 +215,11 @@ class GraphBuilderWithProvenance:
|
||||
entity_id=entity_id,
|
||||
source="graph_construction",
|
||||
entity_type="graph_entity",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
metadata={
|
||||
"operation": "build_entity",
|
||||
"entity_type": entity.get('type'),
|
||||
@@ -197,6 +235,11 @@ class GraphBuilderWithProvenance:
|
||||
entity_id=rel_id,
|
||||
source="graph_construction",
|
||||
entity_type="graph_relationship",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
metadata={
|
||||
"operation": "build_relationship",
|
||||
"relationship_type": relationship.get('type'),
|
||||
@@ -247,9 +290,17 @@ class AlgorithmTrackerWithProvenance:
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(self, provenance: bool = False, **config):
|
||||
def __init__(
|
||||
self,
|
||||
provenance: bool = False,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**config,
|
||||
):
|
||||
self.provenance = provenance
|
||||
self._prov_manager = None
|
||||
self._agent_id = agent_id or self.__class__.__name__
|
||||
self._is_automated = is_automated
|
||||
|
||||
if provenance:
|
||||
try:
|
||||
@@ -264,7 +315,8 @@ class AlgorithmTrackerWithProvenance:
|
||||
algorithm: str,
|
||||
embeddings: Dict[str, List[float]],
|
||||
parameters: Dict[str, Any],
|
||||
source: str = None
|
||||
source: str = None,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
Track node embedding algorithm computation with provenance.
|
||||
@@ -286,6 +338,11 @@ class AlgorithmTrackerWithProvenance:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=execution_id,
|
||||
source=source or "algorithm_execution",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=kwargs.get("activity_started_at_time"),
|
||||
activity_ended_at_time=kwargs.get("activity_ended_at_time"),
|
||||
metadata={
|
||||
"entity_type": "embedding_computation",
|
||||
"algorithm": algorithm,
|
||||
@@ -303,6 +360,11 @@ class AlgorithmTrackerWithProvenance:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=f"embedding_{node_id}",
|
||||
source=source or "algorithm_execution",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=kwargs.get("activity_started_at_time"),
|
||||
activity_ended_at_time=kwargs.get("activity_ended_at_time"),
|
||||
metadata={
|
||||
"entity_type": "node_embedding",
|
||||
"algorithm": algorithm,
|
||||
@@ -345,6 +407,11 @@ class AlgorithmTrackerWithProvenance:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=execution_id,
|
||||
source=source or "algorithm_execution",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=kwargs.get("activity_started_at_time"),
|
||||
activity_ended_at_time=kwargs.get("activity_ended_at_time"),
|
||||
metadata={
|
||||
"entity_type": "similarity_calculation",
|
||||
"algorithm": f"similarity_{method}",
|
||||
@@ -363,6 +430,11 @@ class AlgorithmTrackerWithProvenance:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=f"similarity_{node_id}_{execution_id}",
|
||||
source=source or "algorithm_execution",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=kwargs.get("activity_started_at_time"),
|
||||
activity_ended_at_time=kwargs.get("activity_ended_at_time"),
|
||||
metadata={
|
||||
"entity_type": "similarity_result",
|
||||
"method": method,
|
||||
@@ -393,6 +465,11 @@ class AlgorithmTrackerWithProvenance:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=execution_id,
|
||||
source=source or "algorithm_execution",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=kwargs.get("activity_started_at_time"),
|
||||
activity_ended_at_time=kwargs.get("activity_ended_at_time"),
|
||||
metadata={
|
||||
"entity_type": "link_prediction",
|
||||
"algorithm": f"link_prediction_{method}",
|
||||
@@ -410,6 +487,11 @@ class AlgorithmTrackerWithProvenance:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=f"prediction_{execution_id}_{i}",
|
||||
source=source or "algorithm_execution",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=kwargs.get("activity_started_at_time"),
|
||||
activity_ended_at_time=kwargs.get("activity_ended_at_time"),
|
||||
metadata={
|
||||
"entity_type": "link_prediction_result",
|
||||
"method": method,
|
||||
@@ -453,6 +535,11 @@ class AlgorithmTrackerWithProvenance:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=execution_id,
|
||||
source=source or "algorithm_execution",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=kwargs.get("activity_started_at_time"),
|
||||
activity_ended_at_time=kwargs.get("activity_ended_at_time"),
|
||||
metadata={
|
||||
"entity_type": "centrality_calculation",
|
||||
"algorithm": f"centrality_{method}",
|
||||
@@ -470,6 +557,11 @@ class AlgorithmTrackerWithProvenance:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=f"centrality_{node_id}_{execution_id}",
|
||||
source=source or "algorithm_execution",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=kwargs.get("activity_started_at_time"),
|
||||
activity_ended_at_time=kwargs.get("activity_ended_at_time"),
|
||||
metadata={
|
||||
"entity_type": "centrality_score",
|
||||
"method": method,
|
||||
@@ -500,6 +592,11 @@ class AlgorithmTrackerWithProvenance:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=execution_id,
|
||||
source=source or "algorithm_execution",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=kwargs.get("activity_started_at_time"),
|
||||
activity_ended_at_time=kwargs.get("activity_ended_at_time"),
|
||||
metadata={
|
||||
"entity_type": "community_detection",
|
||||
"algorithm": f"community_detection_{method}",
|
||||
@@ -517,6 +614,11 @@ class AlgorithmTrackerWithProvenance:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=f"community_{execution_id}_{i}",
|
||||
source=source or "algorithm_execution",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=kwargs.get("activity_started_at_time"),
|
||||
activity_ended_at_time=kwargs.get("activity_ended_at_time"),
|
||||
metadata={
|
||||
"entity_type": "community",
|
||||
"method": method,
|
||||
@@ -547,6 +649,11 @@ class AlgorithmTrackerWithProvenance:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=execution_id,
|
||||
source=source or "graph_construction",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=kwargs.get("activity_started_at_time"),
|
||||
activity_ended_at_time=kwargs.get("activity_ended_at_time"),
|
||||
metadata={
|
||||
"entity_type": "graph_construction",
|
||||
"entities_count": entities_count,
|
||||
@@ -573,6 +680,11 @@ class AlgorithmTrackerWithProvenance:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=result_id,
|
||||
source=source or "similarity_result",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=kwargs.get("activity_started_at_time"),
|
||||
activity_ended_at_time=kwargs.get("activity_ended_at_time"),
|
||||
metadata={
|
||||
"entity_type": "similarity_result",
|
||||
"node_id": node_id,
|
||||
@@ -599,6 +711,11 @@ class AlgorithmTrackerWithProvenance:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=result_id,
|
||||
source=source or "similarity_threshold",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=kwargs.get("activity_started_at_time"),
|
||||
activity_ended_at_time=kwargs.get("activity_ended_at_time"),
|
||||
metadata={
|
||||
"entity_type": "similarity_threshold_analysis",
|
||||
"execution_id": execution_id,
|
||||
@@ -624,6 +741,11 @@ class AlgorithmTrackerWithProvenance:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=result_id,
|
||||
source=source or "entity_processing",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=kwargs.get("activity_started_at_time"),
|
||||
activity_ended_at_time=kwargs.get("activity_ended_at_time"),
|
||||
metadata={
|
||||
"entity_type": "entity_processing",
|
||||
"processed_entity_id": entity_id,
|
||||
@@ -648,6 +770,11 @@ class AlgorithmTrackerWithProvenance:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=result_id,
|
||||
source=source or "relationship_processing",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=kwargs.get("activity_started_at_time"),
|
||||
activity_ended_at_time=kwargs.get("activity_ended_at_time"),
|
||||
metadata={
|
||||
"entity_type": "relationship_processing",
|
||||
"processed_relationship_id": relationship_id,
|
||||
@@ -672,6 +799,11 @@ class AlgorithmTrackerWithProvenance:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=result_id,
|
||||
source=source or "path_analysis",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=kwargs.get("activity_started_at_time"),
|
||||
activity_ended_at_time=kwargs.get("activity_ended_at_time"),
|
||||
metadata={
|
||||
"entity_type": "path_analysis",
|
||||
"paths_count": len(paths) if paths else 0,
|
||||
@@ -700,6 +832,11 @@ class AlgorithmTrackerWithProvenance:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=result_id,
|
||||
source=source or "path_finding",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=kwargs.get("activity_started_at_time"),
|
||||
activity_ended_at_time=kwargs.get("activity_ended_at_time"),
|
||||
metadata={
|
||||
"entity_type": "path_finding",
|
||||
"source_node": source_node,
|
||||
@@ -724,6 +861,11 @@ class AlgorithmTrackerWithProvenance:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=result_id,
|
||||
source=source or "embedding_analysis",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=kwargs.get("activity_started_at_time"),
|
||||
activity_ended_at_time=kwargs.get("activity_ended_at_time"),
|
||||
metadata={
|
||||
"entity_type": "embedding_analysis",
|
||||
"embeddings_count": len(embeddings),
|
||||
@@ -746,6 +888,11 @@ class AlgorithmTrackerWithProvenance:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=result_id,
|
||||
source=source or "connectivity_analysis",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=kwargs.get("activity_started_at_time"),
|
||||
activity_ended_at_time=kwargs.get("activity_ended_at_time"),
|
||||
metadata={
|
||||
"entity_type": "connectivity_analysis",
|
||||
"components_count": len(components),
|
||||
@@ -768,6 +915,11 @@ class AlgorithmTrackerWithProvenance:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=result_id,
|
||||
source=source or "cross_layer_analysis",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=kwargs.get("activity_started_at_time"),
|
||||
activity_ended_at_time=kwargs.get("activity_ended_at_time"),
|
||||
metadata={
|
||||
"entity_type": "cross_layer_analysis",
|
||||
"layers_count": len(cross_layer_results) if cross_layer_results else 0,
|
||||
@@ -793,6 +945,11 @@ class AlgorithmTrackerWithProvenance:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=result_id,
|
||||
source=source or "pipeline_summary",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=kwargs.get("activity_started_at_time"),
|
||||
activity_ended_at_time=kwargs.get("activity_ended_at_time"),
|
||||
metadata={
|
||||
"entity_type": "pipeline_summary",
|
||||
"pipeline_id": pipeline_id,
|
||||
@@ -820,6 +977,11 @@ class AlgorithmTrackerWithProvenance:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=summary_id,
|
||||
source=source or "workflow_summary",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=kwargs.get("activity_started_at_time"),
|
||||
activity_ended_at_time=kwargs.get("activity_ended_at_time"),
|
||||
metadata={
|
||||
"entity_type": "workflow_summary",
|
||||
"master_workflow_id": master_workflow_id,
|
||||
@@ -848,6 +1010,11 @@ class AlgorithmTrackerWithProvenance:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=result_id,
|
||||
source=source or "link_prediction_result",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=kwargs.get("activity_started_at_time"),
|
||||
activity_ended_at_time=kwargs.get("activity_ended_at_time"),
|
||||
metadata={
|
||||
"entity_type": "link_prediction_result",
|
||||
"source_node": source_node,
|
||||
@@ -869,6 +1036,11 @@ class AlgorithmTrackerWithProvenance:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=result_id,
|
||||
source=source or analysis_type,
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=kwargs.get("activity_started_at_time"),
|
||||
activity_ended_at_time=kwargs.get("activity_ended_at_time"),
|
||||
metadata={"entity_type": analysis_type, "timestamp": time.time(), **{k: str(v)[:100] for k, v in kwargs.items() if not callable(v)}},
|
||||
)
|
||||
return result_id
|
||||
|
||||
@@ -42,6 +42,7 @@ License: MIT
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
import time
|
||||
import uuid
|
||||
|
||||
@@ -54,16 +55,27 @@ class LLMProvenanceMixin:
|
||||
LLM API calls including tokens, costs, and performance metrics.
|
||||
"""
|
||||
|
||||
def __init__(self, provenance: bool = False, **kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
provenance: bool = False,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Initialize LLM provenance tracking.
|
||||
|
||||
Args:
|
||||
provenance: Enable provenance tracking (default: False)
|
||||
agent_id: Agent identifier for accountability (issue #825); defaults
|
||||
to the wrapping class name
|
||||
is_automated: Whether this agent acted without direct human review
|
||||
**kwargs: Additional arguments passed to parent class
|
||||
"""
|
||||
self.provenance = provenance
|
||||
self._prov_manager = None
|
||||
self._agent_id = agent_id or self.__class__.__name__
|
||||
self._is_automated = is_automated
|
||||
|
||||
if provenance:
|
||||
try:
|
||||
@@ -99,10 +111,21 @@ class LLMProvenanceMixin:
|
||||
elif not isinstance(response, str):
|
||||
response_text = str(response)
|
||||
|
||||
# Typed Activity timing (issue #825, Part B Tier 1): popped out so
|
||||
# it populates real fields, not the opaque metadata blob.
|
||||
activity_started_at_time = metadata.pop("activity_started_at_time", None)
|
||||
activity_ended_at_time = metadata.pop("activity_ended_at_time", None)
|
||||
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=call_id,
|
||||
source=f"{self.__class__.__name__}_api",
|
||||
entity_type="llm_generation",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_id=call_id,
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
metadata={
|
||||
"model": getattr(self, 'model', 'unknown'),
|
||||
"prompt_preview": prompt[:200] if len(prompt) > 200 else prompt,
|
||||
@@ -124,7 +147,13 @@ class GroqLLMWithProvenance(LLMProvenanceMixin):
|
||||
>>> # API call is tracked with model, tokens, cost, latency
|
||||
"""
|
||||
|
||||
def __init__(self, provenance: bool = False, **config):
|
||||
def __init__(
|
||||
self,
|
||||
provenance: bool = False,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**config,
|
||||
):
|
||||
"""
|
||||
Initialize Groq LLM with optional provenance.
|
||||
|
||||
@@ -134,7 +163,9 @@ class GroqLLMWithProvenance(LLMProvenanceMixin):
|
||||
"""
|
||||
from .groq_llm import GroqLLM
|
||||
|
||||
LLMProvenanceMixin.__init__(self, provenance=provenance)
|
||||
LLMProvenanceMixin.__init__(
|
||||
self, provenance=provenance, agent_id=agent_id, is_automated=is_automated
|
||||
)
|
||||
self._llm = GroqLLM(**config)
|
||||
self.model = getattr(self._llm, 'model', 'groq')
|
||||
|
||||
@@ -150,8 +181,10 @@ class GroqLLMWithProvenance(LLMProvenanceMixin):
|
||||
LLM response (same format as original GroqLLM)
|
||||
"""
|
||||
start_time = time.time()
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
response = self._llm.generate(prompt, **kwargs)
|
||||
elapsed = time.time() - start_time
|
||||
activity_ended_at_time = datetime.utcnow().isoformat()
|
||||
|
||||
if self.provenance:
|
||||
# Extract token counts if available
|
||||
@@ -175,6 +208,8 @@ class GroqLLMWithProvenance(LLMProvenanceMixin):
|
||||
total_tokens=(prompt_tokens + completion_tokens) if (prompt_tokens and completion_tokens) else None,
|
||||
total_cost=total_cost,
|
||||
latency_seconds=elapsed,
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
temperature=kwargs.get('temperature'),
|
||||
max_tokens=kwargs.get('max_tokens'),
|
||||
top_p=kwargs.get('top_p')
|
||||
@@ -194,7 +229,13 @@ class OpenAILLMWithProvenance(LLMProvenanceMixin):
|
||||
Wraps the original OpenAILLM and tracks all API calls.
|
||||
"""
|
||||
|
||||
def __init__(self, provenance: bool = False, **config):
|
||||
def __init__(
|
||||
self,
|
||||
provenance: bool = False,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**config,
|
||||
):
|
||||
"""
|
||||
Initialize OpenAI LLM with optional provenance.
|
||||
|
||||
@@ -204,7 +245,9 @@ class OpenAILLMWithProvenance(LLMProvenanceMixin):
|
||||
"""
|
||||
from .openai_llm import OpenAILLM
|
||||
|
||||
LLMProvenanceMixin.__init__(self, provenance=provenance)
|
||||
LLMProvenanceMixin.__init__(
|
||||
self, provenance=provenance, agent_id=agent_id, is_automated=is_automated
|
||||
)
|
||||
self._llm = OpenAILLM(**config)
|
||||
self.model = getattr(self._llm, 'model', 'openai')
|
||||
|
||||
@@ -220,8 +263,10 @@ class OpenAILLMWithProvenance(LLMProvenanceMixin):
|
||||
LLM response
|
||||
"""
|
||||
start_time = time.time()
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
response = self._llm.generate(prompt, **kwargs)
|
||||
elapsed = time.time() - start_time
|
||||
activity_ended_at_time = datetime.utcnow().isoformat()
|
||||
|
||||
if self.provenance:
|
||||
# Extract token counts if available
|
||||
@@ -245,6 +290,8 @@ class OpenAILLMWithProvenance(LLMProvenanceMixin):
|
||||
total_tokens=(prompt_tokens + completion_tokens) if (prompt_tokens and completion_tokens) else None,
|
||||
total_cost=total_cost,
|
||||
latency_seconds=elapsed,
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
temperature=kwargs.get('temperature'),
|
||||
max_tokens=kwargs.get('max_tokens')
|
||||
)
|
||||
@@ -263,7 +310,13 @@ class HuggingFaceLLMWithProvenance(LLMProvenanceMixin):
|
||||
Wraps the original HuggingFaceLLM and tracks all generations.
|
||||
"""
|
||||
|
||||
def __init__(self, provenance: bool = False, **config):
|
||||
def __init__(
|
||||
self,
|
||||
provenance: bool = False,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**config,
|
||||
):
|
||||
"""
|
||||
Initialize HuggingFace LLM with optional provenance.
|
||||
|
||||
@@ -273,7 +326,9 @@ class HuggingFaceLLMWithProvenance(LLMProvenanceMixin):
|
||||
"""
|
||||
from .huggingface_llm import HuggingFaceLLM
|
||||
|
||||
LLMProvenanceMixin.__init__(self, provenance=provenance)
|
||||
LLMProvenanceMixin.__init__(
|
||||
self, provenance=provenance, agent_id=agent_id, is_automated=is_automated
|
||||
)
|
||||
self._llm = HuggingFaceLLM(**config)
|
||||
self.model = getattr(self._llm, 'model', 'huggingface')
|
||||
|
||||
@@ -289,8 +344,10 @@ class HuggingFaceLLMWithProvenance(LLMProvenanceMixin):
|
||||
LLM response
|
||||
"""
|
||||
start_time = time.time()
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
response = self._llm.generate(prompt, **kwargs)
|
||||
elapsed = time.time() - start_time
|
||||
activity_ended_at_time = datetime.utcnow().isoformat()
|
||||
|
||||
if self.provenance:
|
||||
self._track_llm_call(
|
||||
@@ -298,6 +355,8 @@ class HuggingFaceLLMWithProvenance(LLMProvenanceMixin):
|
||||
prompt=prompt,
|
||||
response=response,
|
||||
latency_seconds=elapsed,
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
max_length=kwargs.get('max_length'),
|
||||
temperature=kwargs.get('temperature')
|
||||
)
|
||||
@@ -316,7 +375,13 @@ class LiteLLMWithProvenance(LLMProvenanceMixin):
|
||||
Wraps the original LiteLLM and tracks all API calls across providers.
|
||||
"""
|
||||
|
||||
def __init__(self, provenance: bool = False, **config):
|
||||
def __init__(
|
||||
self,
|
||||
provenance: bool = False,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**config,
|
||||
):
|
||||
"""
|
||||
Initialize LiteLLM with optional provenance.
|
||||
|
||||
@@ -326,7 +391,9 @@ class LiteLLMWithProvenance(LLMProvenanceMixin):
|
||||
"""
|
||||
from .lite_llm import LiteLLM
|
||||
|
||||
LLMProvenanceMixin.__init__(self, provenance=provenance)
|
||||
LLMProvenanceMixin.__init__(
|
||||
self, provenance=provenance, agent_id=agent_id, is_automated=is_automated
|
||||
)
|
||||
self._llm = LiteLLM(**config)
|
||||
self.model = getattr(self._llm, 'model', 'litellm')
|
||||
|
||||
@@ -342,8 +409,10 @@ class LiteLLMWithProvenance(LLMProvenanceMixin):
|
||||
LLM response
|
||||
"""
|
||||
start_time = time.time()
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
response = self._llm.generate(prompt, **kwargs)
|
||||
elapsed = time.time() - start_time
|
||||
activity_ended_at_time = datetime.utcnow().isoformat()
|
||||
|
||||
if self.provenance:
|
||||
# LiteLLM provides unified response format
|
||||
@@ -366,6 +435,8 @@ class LiteLLMWithProvenance(LLMProvenanceMixin):
|
||||
completion_tokens=completion_tokens,
|
||||
total_cost=total_cost,
|
||||
latency_seconds=elapsed,
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
provider=kwargs.get('provider')
|
||||
)
|
||||
|
||||
|
||||
@@ -11,19 +11,28 @@ Author: Semantica Contributors
|
||||
License: MIT
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
class NormalizerWithProvenance:
|
||||
"""Normalizer with provenance tracking."""
|
||||
|
||||
def __init__(self, provenance: bool = False, **config):
|
||||
def __init__(
|
||||
self,
|
||||
provenance: bool = False,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**config,
|
||||
):
|
||||
from .normalizer import Normalizer
|
||||
|
||||
self.provenance = provenance
|
||||
self._normalizer = Normalizer(**config)
|
||||
self._prov_manager = None
|
||||
self._agent_id = agent_id or self.__class__.__name__
|
||||
self._is_automated = is_automated
|
||||
|
||||
if provenance:
|
||||
try:
|
||||
@@ -34,13 +43,20 @@ class NormalizerWithProvenance:
|
||||
|
||||
def normalize(self, data: Any, source: str = None, **kwargs):
|
||||
"""Normalize data with provenance tracking."""
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
result = self._normalizer.normalize(data, **kwargs)
|
||||
activity_ended_at_time = datetime.utcnow().isoformat()
|
||||
|
||||
if self.provenance and self._prov_manager:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=f"normalize_{uuid.uuid4().hex[:8]}",
|
||||
source=source or "normalization",
|
||||
entity_type="normalized_data",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
metadata={"method": kwargs.get('method', 'default')}
|
||||
)
|
||||
|
||||
|
||||
@@ -11,19 +11,28 @@ Author: Semantica Contributors
|
||||
License: MIT
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
class OntologyManagerWithProvenance:
|
||||
"""Ontology manager with provenance tracking."""
|
||||
|
||||
def __init__(self, provenance: bool = False, **config):
|
||||
def __init__(
|
||||
self,
|
||||
provenance: bool = False,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**config,
|
||||
):
|
||||
from .ontology_manager import OntologyManager
|
||||
|
||||
self.provenance = provenance
|
||||
self._manager = OntologyManager(**config)
|
||||
self._prov_manager = None
|
||||
self._agent_id = agent_id or self.__class__.__name__
|
||||
self._is_automated = is_automated
|
||||
|
||||
if provenance:
|
||||
try:
|
||||
@@ -34,13 +43,20 @@ class OntologyManagerWithProvenance:
|
||||
|
||||
def add_concept(self, concept: Any, source: str = None, **kwargs):
|
||||
"""Add concept with provenance tracking."""
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
result = self._manager.add_concept(concept, **kwargs)
|
||||
activity_ended_at_time = datetime.utcnow().isoformat()
|
||||
|
||||
if self.provenance and self._prov_manager:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=f"concept_{uuid.uuid4().hex[:8]}",
|
||||
source=source or "ontology",
|
||||
entity_type="ontology_concept",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
metadata={"concept_name": str(concept)}
|
||||
)
|
||||
|
||||
|
||||
@@ -13,19 +13,28 @@ Author: Semantica Contributors
|
||||
License: MIT
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
class ParserWithProvenance:
|
||||
"""Base parser with provenance tracking."""
|
||||
|
||||
def __init__(self, provenance: bool = False, **config):
|
||||
def __init__(
|
||||
self,
|
||||
provenance: bool = False,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**config,
|
||||
):
|
||||
from .parser import Parser
|
||||
|
||||
self.provenance = provenance
|
||||
self._parser = Parser(**config)
|
||||
self._prov_manager = None
|
||||
self._agent_id = agent_id or self.__class__.__name__
|
||||
self._is_automated = is_automated
|
||||
|
||||
if provenance:
|
||||
try:
|
||||
@@ -36,13 +45,20 @@ class ParserWithProvenance:
|
||||
|
||||
def parse(self, file_path: str, **kwargs):
|
||||
"""Parse file with provenance tracking."""
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
data = self._parser.parse(file_path, **kwargs)
|
||||
activity_ended_at_time = datetime.utcnow().isoformat()
|
||||
|
||||
if self.provenance and self._prov_manager:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=f"parse_{uuid.uuid4().hex[:8]}",
|
||||
source=file_path,
|
||||
entity_type="parsed_data",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
metadata={
|
||||
"file_path": file_path,
|
||||
"format": kwargs.get('format', 'unknown')
|
||||
|
||||
@@ -16,6 +16,7 @@ License: MIT
|
||||
"""
|
||||
|
||||
from typing import Optional, Any, Dict, List
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
import time
|
||||
|
||||
@@ -23,13 +24,21 @@ import time
|
||||
class PipelineWithProvenance:
|
||||
"""Pipeline executor with complete provenance tracking."""
|
||||
|
||||
def __init__(self, provenance: bool = False, **config):
|
||||
def __init__(
|
||||
self,
|
||||
provenance: bool = False,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**config,
|
||||
):
|
||||
"""Initialize pipeline with optional provenance."""
|
||||
from .pipeline import Pipeline
|
||||
|
||||
self.provenance = provenance
|
||||
self._pipeline = Pipeline(**config)
|
||||
self._prov_manager = None
|
||||
self._agent_id = agent_id or self.__class__.__name__
|
||||
self._is_automated = is_automated
|
||||
|
||||
if provenance:
|
||||
try:
|
||||
@@ -42,15 +51,23 @@ class PipelineWithProvenance:
|
||||
"""Run pipeline with provenance tracking."""
|
||||
pipeline_id = f"pipeline_{uuid.uuid4().hex[:8]}"
|
||||
start_time = time.time()
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
|
||||
result = self._pipeline.run(data, **kwargs)
|
||||
elapsed = time.time() - start_time
|
||||
activity_ended_at_time = datetime.utcnow().isoformat()
|
||||
|
||||
if self.provenance and self._prov_manager:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=pipeline_id,
|
||||
source=source or "pipeline_execution",
|
||||
entity_type="pipeline_run",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_id=pipeline_id,
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
metadata={
|
||||
"steps": len(self._pipeline.steps) if hasattr(self._pipeline, 'steps') else 0,
|
||||
"duration_seconds": elapsed,
|
||||
|
||||
@@ -41,7 +41,13 @@ Author: Semantica Contributors
|
||||
License: MIT
|
||||
"""
|
||||
|
||||
from .schemas import ProvenanceEntry, SourceReference
|
||||
from .schemas import (
|
||||
ProvenanceEntry,
|
||||
SourceReference,
|
||||
AgentRecord,
|
||||
ActivityRecord,
|
||||
Invalidation,
|
||||
)
|
||||
from .storage import ProvenanceStorage, InMemoryStorage, SQLiteStorage
|
||||
from .manager import ProvenanceManager, default_storage_path
|
||||
from .integrity import compute_checksum, verify_checksum
|
||||
@@ -50,6 +56,9 @@ __all__ = [
|
||||
# Core schemas
|
||||
"ProvenanceEntry",
|
||||
"SourceReference",
|
||||
"AgentRecord",
|
||||
"ActivityRecord",
|
||||
"Invalidation",
|
||||
|
||||
# Storage backends
|
||||
"ProvenanceStorage",
|
||||
|
||||
@@ -31,6 +31,29 @@ def compute_checksum(entry: Any) -> str:
|
||||
Creates a deterministic checksum based on critical provenance fields
|
||||
to detect any tampering or corruption of provenance data.
|
||||
|
||||
Includes `previous_checksum` (issue #825, Part A item 2), which chains
|
||||
each entry to the prior entry in insertion order (see
|
||||
ProvenanceStorage.get_chain_head / ProvenanceManager.verify_chain) —
|
||||
wholesale deletion of a row breaks the chain for the entry that used to
|
||||
follow it, making the deletion detectable even though per-row checksums
|
||||
only prove a surviving row wasn't edited in place. Also includes
|
||||
agent_id/agent_type and the lineage-link fields (parent_entity_id,
|
||||
previous_version_id, derived_from_id, used_entities) so tampering with
|
||||
attribution or lineage is detected, not just tampering with the six
|
||||
original fields.
|
||||
|
||||
Deliberately excludes `entity_id` itself: entity_id is the storage
|
||||
primary key, and ProvenanceManager.track_entity()'s versioning archives
|
||||
a prior value by copying it to a new entity_id (e.g. "X" -> "X:v:...").
|
||||
If entity_id were hashed, that relabeling would change the archived
|
||||
copy's checksum, permanently orphaning any later entry whose
|
||||
previous_checksum had already chained from the pre-relabel value —
|
||||
a false-positive "broken chain" for a legitimate rename, not tampering.
|
||||
Tampering that swaps a row's entity_id while keeping its content is a
|
||||
narrower threat than content tampering, already partially caught by
|
||||
verify_chain() (it requires a delete+insert, which breaks the chain for
|
||||
whatever the deleted row's successor was).
|
||||
|
||||
Args:
|
||||
entry: ProvenanceEntry or dict to compute checksum for
|
||||
|
||||
@@ -48,24 +71,46 @@ def compute_checksum(entry: Any) -> str:
|
||||
>>> print(checksum)
|
||||
'a3b2c1d4e5f6...'
|
||||
"""
|
||||
# Concatenate critical fields for checksum
|
||||
# Concatenate critical fields for checksum (entity_id intentionally excluded, see docstring)
|
||||
if isinstance(entry, dict):
|
||||
used_entities = entry.get("used_entities") or []
|
||||
data = (
|
||||
f"{entry.get('entity_id') or ''}"
|
||||
f"{entry.get('entity_type') or ''}"
|
||||
f"{entry.get('activity_id') or ''}"
|
||||
f"{entry.get('agent_id') or ''}"
|
||||
f"{entry.get('agent_type') or ''}"
|
||||
f"{entry.get('source_document') or ''}"
|
||||
f"{entry.get('timestamp') or ''}"
|
||||
f"{entry.get('confidence') if entry.get('confidence') is not None else 1.0}"
|
||||
f"{entry.get('parent_entity_id') or ''}"
|
||||
f"{entry.get('previous_version_id') or ''}"
|
||||
f"{entry.get('derived_from_id') or ''}"
|
||||
f"{','.join(used_entities)}"
|
||||
f"{entry.get('previous_checksum') or ''}"
|
||||
f"{bool(entry.get('invalidated'))}"
|
||||
f"{entry.get('invalidated_at_time') or ''}"
|
||||
f"{entry.get('invalidated_by') or ''}"
|
||||
f"{entry.get('invalidation_reason') or ''}"
|
||||
)
|
||||
else:
|
||||
used_entities = getattr(entry, "used_entities", None) or []
|
||||
data = (
|
||||
f"{entry.entity_id}"
|
||||
f"{entry.entity_type}"
|
||||
f"{entry.activity_id}"
|
||||
f"{getattr(entry, 'agent_id', '') or ''}"
|
||||
f"{getattr(entry, 'agent_type', '') or ''}"
|
||||
f"{entry.source_document}"
|
||||
f"{entry.timestamp}"
|
||||
f"{entry.confidence}"
|
||||
f"{getattr(entry, 'parent_entity_id', '') or ''}"
|
||||
f"{getattr(entry, 'previous_version_id', '') or ''}"
|
||||
f"{getattr(entry, 'derived_from_id', '') or ''}"
|
||||
f"{','.join(used_entities)}"
|
||||
f"{getattr(entry, 'previous_checksum', '') or ''}"
|
||||
f"{bool(getattr(entry, 'invalidated', False))}"
|
||||
f"{getattr(entry, 'invalidated_at_time', '') or ''}"
|
||||
f"{getattr(entry, 'invalidated_by', '') or ''}"
|
||||
f"{getattr(entry, 'invalidation_reason', '') or ''}"
|
||||
)
|
||||
|
||||
return hashlib.sha256(data.encode('utf-8')).hexdigest()
|
||||
|
||||
+588
-22
@@ -33,11 +33,18 @@ import inspect
|
||||
import json
|
||||
import threading
|
||||
|
||||
from .schemas import ProvenanceEntry, SourceReference
|
||||
from .schemas import ProvenanceEntry, SourceReference, AgentRecord, ActivityRecord
|
||||
from .storage import ProvenanceStorage, InMemoryStorage, SQLiteStorage
|
||||
from .integrity import compute_checksum, verify_checksum
|
||||
from ..utils.logging import get_logger
|
||||
|
||||
# Issue #825, Part B Tier 3 — configurable base URI for export_prov(), shared
|
||||
# with RDFExporter's NamespaceManager "semantica" entry (semantica/export/
|
||||
# rdf_exporter.py) so KG-exported and PROV-exported URIs for the same
|
||||
# entity_id co-resolve to the same namespace instead of two different
|
||||
# placeholder domains.
|
||||
DEFAULT_BASE_URI = "https://semantica.dev/ns#"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def default_storage_path(path: Optional[str]):
|
||||
@@ -146,6 +153,16 @@ class ProvenanceManager:
|
||||
_raise_on_error: bool = False,
|
||||
) -> Optional[ProvenanceEntry]:
|
||||
"""Compute checksum, store entry persistently, and log/handle storage errors (#783)."""
|
||||
# Hash-chain linkage (issue #825, Part A item 2): link this entry to
|
||||
# the previous entry in global insertion order before hashing, so
|
||||
# deleting a row later breaks the chain for whatever followed it.
|
||||
try:
|
||||
head = self.storage.get_chain_head(_conn)
|
||||
except Exception:
|
||||
head = None
|
||||
entry.sequence_id = (head[0] + 1) if head else 1
|
||||
entry.previous_checksum = head[1] if head else None
|
||||
|
||||
entry.checksum = compute_checksum(entry)
|
||||
|
||||
try:
|
||||
@@ -178,6 +195,69 @@ class ProvenanceManager:
|
||||
with self.storage.transaction() as conn:
|
||||
yield conn
|
||||
|
||||
# Recognized typed kwargs for track_entity, used both directly and to
|
||||
# split track_entities_batch's **metadata into real kwargs vs. free-form
|
||||
# metadata (issue #825, Part A item 3 — fixes a bug where agent_id/
|
||||
# entity_type/activity_id passed to track_entities_batch were silently
|
||||
# absorbed into the opaque metadata blob instead of populating fields).
|
||||
_TRACK_ENTITY_KWARGS = frozenset({
|
||||
"entity_type", "activity_id", "agent_id", "agent_type", "is_automated",
|
||||
"role", "agent", "source_location", "source_quote", "confidence",
|
||||
"parent_entity_id", "used_entities",
|
||||
# Part B Tier 1/2/3 (issue #825)
|
||||
"activity", "activity_started_at_time", "activity_ended_at_time",
|
||||
"acted_on_behalf_of", "informed_by", "valid_from", "valid_until",
|
||||
"revision_type", "supersedes", "bundle_id",
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _resolve_agent_kwargs(kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Resolve agent_id/agent_type/is_automated/role from kwargs.
|
||||
|
||||
Accepts either an `agent=AgentRecord(...)` kwarg (takes precedence)
|
||||
or individual `agent_id`/`agent_type`/`is_automated` scalar kwargs.
|
||||
Fixes issue #825's dead agent_id field: previously no track_*
|
||||
method read agent_id from kwargs at all, so it was always the
|
||||
dataclass default "semantica" regardless of what callers passed.
|
||||
"""
|
||||
agent = kwargs.get("agent")
|
||||
if isinstance(agent, AgentRecord):
|
||||
return {
|
||||
"agent_id": agent.id,
|
||||
"agent_type": agent.agent_type,
|
||||
"is_automated": agent.is_automated,
|
||||
"role": kwargs.get("role"),
|
||||
}
|
||||
return {
|
||||
"agent_id": kwargs.get("agent_id", "semantica"),
|
||||
"agent_type": kwargs.get("agent_type", "software_agent"),
|
||||
"is_automated": kwargs.get("is_automated", True),
|
||||
"role": kwargs.get("role"),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _resolve_activity_kwargs(kwargs: Dict[str, Any], default_activity_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Resolve activity_id/activity_started_at_time/activity_ended_at_time
|
||||
from kwargs (issue #825, Part B Tier 1 — typed Activity).
|
||||
|
||||
Accepts either an `activity=ActivityRecord(...)` kwarg (takes
|
||||
precedence) or individual scalar kwargs.
|
||||
"""
|
||||
activity = kwargs.get("activity")
|
||||
if isinstance(activity, ActivityRecord):
|
||||
return {
|
||||
"activity_id": activity.id,
|
||||
"activity_started_at_time": activity.started_at_time,
|
||||
"activity_ended_at_time": activity.ended_at_time,
|
||||
}
|
||||
return {
|
||||
"activity_id": kwargs.get("activity_id", default_activity_id),
|
||||
"activity_started_at_time": kwargs.get("activity_started_at_time"),
|
||||
"activity_ended_at_time": kwargs.get("activity_ended_at_time"),
|
||||
}
|
||||
|
||||
# === Entity Tracking (from kg.ProvenanceTracker) ===
|
||||
|
||||
def track_entity(
|
||||
@@ -253,15 +333,31 @@ class ProvenanceManager:
|
||||
|
||||
history_entry.entity_id = history_id
|
||||
|
||||
# Pure relabel: checksum/sequence_id/previous_checksum are
|
||||
# left exactly as they were. compute_checksum() excludes
|
||||
# entity_id specifically so this is safe — recomputing it
|
||||
# here (or assigning a fresh sequence slot) would either
|
||||
# invalidate any later entry that already chained from
|
||||
# this row's checksum, or strand the original sequence
|
||||
# position and make verify_chain() see a phantom gap
|
||||
# (issue #825: an archival relabel is not tampering and
|
||||
# must not look like it to the hash chain).
|
||||
self.storage._store_with_conn(conn, history_entry)
|
||||
archived_history_id = history_id
|
||||
if not explicit_parent_supplied:
|
||||
parent_id = history_id
|
||||
|
||||
agent_info = self._resolve_agent_kwargs(kwargs)
|
||||
activity_info = self._resolve_activity_kwargs(kwargs, "entity_tracking")
|
||||
|
||||
entry = ProvenanceEntry(
|
||||
entity_id=entity_id,
|
||||
entity_type=kwargs.get("entity_type", "entity"),
|
||||
activity_id=kwargs.get("activity_id", "entity_tracking"),
|
||||
activity_id=activity_info["activity_id"],
|
||||
agent_id=agent_info["agent_id"],
|
||||
agent_type=agent_info["agent_type"],
|
||||
is_automated=agent_info["is_automated"],
|
||||
role=agent_info["role"],
|
||||
source_document=source,
|
||||
source_location=kwargs.get("source_location"),
|
||||
source_quote=kwargs.get("source_quote"),
|
||||
@@ -271,8 +367,29 @@ class ProvenanceManager:
|
||||
last_updated=datetime.utcnow().isoformat(),
|
||||
parent_entity_id=parent_id,
|
||||
used_entities=list(kwargs.get("used_entities", [])),
|
||||
activity_started_at_time=activity_info["activity_started_at_time"],
|
||||
activity_ended_at_time=activity_info["activity_ended_at_time"],
|
||||
acted_on_behalf_of=kwargs.get("acted_on_behalf_of"),
|
||||
informed_by_activities=list(kwargs.get("informed_by", [])),
|
||||
valid_from=kwargs.get("valid_from"),
|
||||
valid_until=kwargs.get("valid_until"),
|
||||
revision_type=kwargs.get("revision_type"),
|
||||
supersedes=kwargs.get("supersedes"),
|
||||
bundle_id=kwargs.get("bundle_id"),
|
||||
)
|
||||
|
||||
# Versioning vs. derivation (issue #825, Part A item 4):
|
||||
# previous_version_id always captures "this corrects a prior
|
||||
# version of the same fact" when one was archived, independent
|
||||
# of whether an explicit cross-source parent was also given.
|
||||
# derived_from_id captures "this fact was derived from a
|
||||
# different source entity" — only set when a parent was
|
||||
# explicitly resolved (kwarg/metadata['derived_from']/source
|
||||
# heuristic), never from the automatic archival link.
|
||||
entry.previous_version_id = archived_history_id
|
||||
if explicit_parent_supplied:
|
||||
entry.derived_from_id = parent_id
|
||||
|
||||
if archived_history_id and explicit_parent_supplied:
|
||||
entry.used_entities.append(archived_history_id)
|
||||
|
||||
@@ -323,16 +440,32 @@ class ProvenanceManager:
|
||||
... metadata={"type": "founded"}
|
||||
... )
|
||||
"""
|
||||
agent_info = self._resolve_agent_kwargs(kwargs)
|
||||
activity_info = self._resolve_activity_kwargs(kwargs, "relationship_tracking")
|
||||
|
||||
entry = ProvenanceEntry(
|
||||
entity_id=relationship_id,
|
||||
entity_type="relationship",
|
||||
activity_id=kwargs.get("activity_id", "relationship_tracking"),
|
||||
activity_id=activity_info["activity_id"],
|
||||
agent_id=agent_info["agent_id"],
|
||||
agent_type=agent_info["agent_type"],
|
||||
is_automated=agent_info["is_automated"],
|
||||
role=agent_info["role"],
|
||||
source_document=source,
|
||||
source_location=kwargs.get("source_location"),
|
||||
confidence=kwargs.get("confidence", 1.0),
|
||||
metadata=metadata or {},
|
||||
first_seen=datetime.utcnow().isoformat(),
|
||||
last_updated=datetime.utcnow().isoformat()
|
||||
last_updated=datetime.utcnow().isoformat(),
|
||||
activity_started_at_time=activity_info["activity_started_at_time"],
|
||||
activity_ended_at_time=activity_info["activity_ended_at_time"],
|
||||
acted_on_behalf_of=kwargs.get("acted_on_behalf_of"),
|
||||
informed_by_activities=list(kwargs.get("informed_by", [])),
|
||||
valid_from=kwargs.get("valid_from"),
|
||||
valid_until=kwargs.get("valid_until"),
|
||||
revision_type=kwargs.get("revision_type"),
|
||||
supersedes=kwargs.get("supersedes"),
|
||||
bundle_id=kwargs.get("bundle_id"),
|
||||
)
|
||||
|
||||
return self._save_entry(entry)
|
||||
@@ -374,17 +507,36 @@ class ProvenanceManager:
|
||||
... end_index=500
|
||||
... )
|
||||
"""
|
||||
agent_info = self._resolve_agent_kwargs(metadata)
|
||||
activity_info = self._resolve_activity_kwargs(metadata, "chunking")
|
||||
for key in (
|
||||
"agent_id", "agent_type", "is_automated", "role", "agent",
|
||||
"activity", "activity_started_at_time", "activity_ended_at_time",
|
||||
):
|
||||
metadata.pop(key, None)
|
||||
|
||||
entry = ProvenanceEntry(
|
||||
entity_id=chunk_id,
|
||||
entity_type="chunk",
|
||||
activity_id="chunking",
|
||||
activity_id=activity_info["activity_id"],
|
||||
agent_id=agent_info["agent_id"],
|
||||
agent_type=agent_info["agent_type"],
|
||||
is_automated=agent_info["is_automated"],
|
||||
role=agent_info["role"],
|
||||
source_document=source_document,
|
||||
source_location=source_path,
|
||||
start_index=start_index,
|
||||
end_index=end_index,
|
||||
parent_entity_id=parent_chunk_id,
|
||||
# A chunk split from a parent chunk is a derivation (a new entity
|
||||
# produced from an existing one), not a correction of the same
|
||||
# fact — see track_entity's previous_version_id/derived_from_id
|
||||
# split (issue #825, Part A item 4).
|
||||
derived_from_id=parent_chunk_id,
|
||||
metadata=metadata,
|
||||
timestamp=datetime.utcnow().isoformat()
|
||||
timestamp=datetime.utcnow().isoformat(),
|
||||
activity_started_at_time=activity_info["activity_started_at_time"],
|
||||
activity_ended_at_time=activity_info["activity_ended_at_time"],
|
||||
)
|
||||
|
||||
return self._save_entry(entry, _conn=_conn, _raise_on_error=(_conn is not None))
|
||||
@@ -425,10 +577,22 @@ class ProvenanceManager:
|
||||
... source=source
|
||||
... )
|
||||
"""
|
||||
agent_info = self._resolve_agent_kwargs(metadata)
|
||||
activity_info = self._resolve_activity_kwargs(metadata, "property_tracking")
|
||||
for key in (
|
||||
"agent_id", "agent_type", "is_automated", "role", "agent",
|
||||
"activity", "activity_started_at_time", "activity_ended_at_time",
|
||||
):
|
||||
metadata.pop(key, None)
|
||||
|
||||
entry = ProvenanceEntry(
|
||||
entity_id=f"{entity_id}_{property_name}",
|
||||
entity_type="property",
|
||||
activity_id="property_tracking",
|
||||
activity_id=activity_info["activity_id"],
|
||||
agent_id=agent_info["agent_id"],
|
||||
agent_type=agent_info["agent_type"],
|
||||
is_automated=agent_info["is_automated"],
|
||||
role=agent_info["role"],
|
||||
source_document=source.document,
|
||||
source_location=f"page_{source.page}" if source.page else source.section,
|
||||
confidence=source.confidence,
|
||||
@@ -440,7 +604,9 @@ class ProvenanceManager:
|
||||
**metadata,
|
||||
**source.metadata
|
||||
},
|
||||
timestamp=datetime.utcnow().isoformat()
|
||||
timestamp=datetime.utcnow().isoformat(),
|
||||
activity_started_at_time=activity_info["activity_started_at_time"],
|
||||
activity_ended_at_time=activity_info["activity_ended_at_time"],
|
||||
)
|
||||
|
||||
return self._save_entry(entry)
|
||||
@@ -474,6 +640,18 @@ class ProvenanceManager:
|
||||
tracked_count = 0
|
||||
batch_size = 1000 # Justification (#807): 1,000 items per transaction bounds SQLite WAL frame growth and reduces lock contention during multi-thousand-row imports while achieving a 1000x reduction in connection/commit overhead.
|
||||
|
||||
# Split batch-level **metadata into recognized typed track_entity
|
||||
# kwargs (entity_type, activity_id, agent_id, ...) vs. free-form data.
|
||||
# Previously ALL of **metadata was merged into the metadata dict and
|
||||
# passed as track_entity's positional `metadata` arg, so typed kwargs
|
||||
# like agent_id/entity_type/activity_id were silently absorbed into
|
||||
# the opaque metadata JSON blob instead of populating real fields
|
||||
# (issue #825, Part A item 3 — this is what made agent_id a "dead"
|
||||
# field for every batch caller, including the documented example in
|
||||
# docs/guides/provenance.md).
|
||||
batch_kwargs = {k: v for k, v in metadata.items() if k in self._TRACK_ENTITY_KWARGS}
|
||||
free_metadata = {k: v for k, v in metadata.items() if k not in self._TRACK_ENTITY_KWARGS}
|
||||
|
||||
for i in range(0, len(entities), batch_size):
|
||||
batch = entities[i : i + batch_size]
|
||||
batch_count = 0
|
||||
@@ -484,11 +662,13 @@ class ProvenanceManager:
|
||||
if not entity_id:
|
||||
continue
|
||||
|
||||
entity_metadata = {**metadata, **entity.get("metadata", {})}
|
||||
entity_metadata = {**free_metadata, **entity.get("metadata", {})}
|
||||
|
||||
try:
|
||||
with self.storage.savepoint(conn):
|
||||
self.track_entity(entity_id, source, entity_metadata, _conn=conn)
|
||||
self.track_entity(
|
||||
entity_id, source, entity_metadata, _conn=conn, **batch_kwargs
|
||||
)
|
||||
batch_count += 1
|
||||
except Exception:
|
||||
pass # Continue with other entities in this batch
|
||||
@@ -655,6 +835,137 @@ class ProvenanceManager:
|
||||
return self.storage.trace_lineage(entity_id, max_depth=max_depth)
|
||||
return self.storage.trace_lineage(entity_id)
|
||||
|
||||
def trace_descendants(
|
||||
self, entity_id: str, max_depth: Optional[int] = None
|
||||
) -> List[ProvenanceEntry]:
|
||||
"""
|
||||
Trace downstream descendants (reverse lineage) and return raw entries.
|
||||
|
||||
This is the counterpart to trace_lineage()/get_lineage(), which only
|
||||
ever trace upstream ancestors via parent_entity_id/used_entities.
|
||||
Downstream traceability answers the incident-response question "entity
|
||||
X was wrong — what downstream facts used it?" (issue #825, Part A
|
||||
item 5).
|
||||
|
||||
Args:
|
||||
entity_id: Entity identifier
|
||||
max_depth: Optional maximum BFS depth
|
||||
|
||||
Returns:
|
||||
List of ProvenanceEntry objects that (transitively) reference
|
||||
entity_id, in BFS order.
|
||||
"""
|
||||
return self.storage.trace_descendants(entity_id, max_depth=max_depth)
|
||||
|
||||
def get_descendants(self, entity_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get downstream descendants for an entity, mirroring get_lineage()'s
|
||||
return shape but for the reverse direction.
|
||||
|
||||
Args:
|
||||
entity_id: Entity identifier
|
||||
|
||||
Returns:
|
||||
Dictionary containing descendant entries, or {} if none found.
|
||||
"""
|
||||
descendant_entries = self.trace_descendants(entity_id)
|
||||
|
||||
if not descendant_entries:
|
||||
return {}
|
||||
|
||||
integrity_verified = all(verify_checksum(entry) for entry in descendant_entries)
|
||||
chain_dicts = [entry.to_dict() for entry in descendant_entries]
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"descendant_chain": chain_dicts,
|
||||
"entries": chain_dicts,
|
||||
"entity_count": len(descendant_entries),
|
||||
"integrity_verified": integrity_verified,
|
||||
}
|
||||
|
||||
def revision_history(self, entity_id: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Return the version history for entity_id in ascending order.
|
||||
|
||||
Issue #825, Part B Tier 3 — closes the "no direct equivalent yet"
|
||||
gap for kg.ProvenanceTracker.revision_history() documented in
|
||||
docs/migration/kg-provenance-tracker.md.
|
||||
|
||||
Walks the entity's own previous_version_id chain (not the full
|
||||
upstream lineage via get_lineage(), which also pulls in unrelated
|
||||
derived_from_id/used_entities links from other source entities).
|
||||
|
||||
Args:
|
||||
entity_id: Entity identifier
|
||||
|
||||
Returns:
|
||||
List of {version, valid_from, valid_until, recorded_at, author,
|
||||
revision_type, supersedes} dicts, oldest first. valid_from/
|
||||
valid_until use the entry's own explicit fields when set (the
|
||||
fact's asserted validity window); otherwise valid_from defaults
|
||||
to when this version was recorded, and valid_until to the next
|
||||
version's timestamp (None for the current/most recent version).
|
||||
Empty list if entity_id was never tracked.
|
||||
"""
|
||||
current = self.storage.retrieve(entity_id)
|
||||
if current is None:
|
||||
return []
|
||||
|
||||
chain: List[ProvenanceEntry] = [current]
|
||||
visited = {entity_id}
|
||||
cursor = current
|
||||
while getattr(cursor, "previous_version_id", None):
|
||||
prev_id = cursor.previous_version_id
|
||||
if prev_id in visited:
|
||||
break
|
||||
prev_entry = self.storage.retrieve(prev_id)
|
||||
if prev_entry is None:
|
||||
break
|
||||
chain.append(prev_entry)
|
||||
visited.add(prev_id)
|
||||
cursor = prev_entry
|
||||
|
||||
chain.reverse() # oldest first
|
||||
|
||||
history = []
|
||||
for i, entry in enumerate(chain):
|
||||
default_valid_until = chain[i + 1].timestamp if i + 1 < len(chain) else None
|
||||
version_dict: Dict[str, Any] = {
|
||||
"version": i + 1,
|
||||
"valid_from": entry.valid_from or entry.timestamp,
|
||||
"valid_until": entry.valid_until or default_valid_until,
|
||||
"recorded_at": entry.timestamp,
|
||||
"author": entry.agent_id,
|
||||
}
|
||||
if entry.revision_type:
|
||||
version_dict["revision_type"] = entry.revision_type
|
||||
if entry.supersedes:
|
||||
version_dict["supersedes"] = entry.supersedes
|
||||
history.append(version_dict)
|
||||
return history
|
||||
|
||||
def query_recorded_between(self, start: str, end: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Return all provenance entries whose timestamp falls within [start, end].
|
||||
|
||||
Issue #825, Part B Tier 3 — closes the "no direct equivalent yet"
|
||||
gap for kg.ProvenanceTracker.query_recorded_between() documented in
|
||||
docs/migration/kg-provenance-tracker.md.
|
||||
|
||||
Args:
|
||||
start: Start of range, ISO 8601 string (inclusive)
|
||||
end: End of range, ISO 8601 string (inclusive)
|
||||
|
||||
Returns:
|
||||
List of matching entries as dicts, sorted by timestamp ascending.
|
||||
"""
|
||||
matches = [
|
||||
e for e in self.storage.retrieve_all()
|
||||
if e.timestamp and start <= e.timestamp <= end
|
||||
]
|
||||
matches.sort(key=lambda e: e.timestamp)
|
||||
return [e.to_dict() for e in matches]
|
||||
|
||||
def get_all_sources(self, entity_id: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all sources for an entity (kg.ProvenanceTracker compatible).
|
||||
@@ -695,12 +1006,92 @@ class ProvenanceManager:
|
||||
return entry.to_dict()
|
||||
return None
|
||||
|
||||
# === Invalidation (tombstone, not hard delete) ===
|
||||
|
||||
def invalidate(
|
||||
self,
|
||||
entity_id: str,
|
||||
agent_id: str,
|
||||
reason: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> ProvenanceEntry:
|
||||
"""
|
||||
Mark a tracked entity as invalidated instead of deleting it
|
||||
(issue #825, Part A item 1 — prov:Invalidation).
|
||||
|
||||
An audit needs "this was deleted/corrected, by whom, when, why" to
|
||||
itself be provable. Rather than mutating the row in place, this
|
||||
archives the pre-invalidation state under a stable versioned key
|
||||
(the same pattern track_entity() uses for corrections) and then
|
||||
writes the invalidated entry as a fresh, chained append. Mutating
|
||||
the existing row's checksum in place would silently invalidate any
|
||||
later entry that had already chained its previous_checksum from
|
||||
this row's pre-invalidation value — turning a legitimate
|
||||
invalidation into a false-positive "broken chain" report.
|
||||
|
||||
The entry remains visible via retrieve()/retrieve_all()/lineage
|
||||
traversal, but callers can filter on `invalidated` to exclude
|
||||
retracted facts.
|
||||
|
||||
Args:
|
||||
entity_id: Entity identifier to invalidate
|
||||
agent_id: Agent responsible for the invalidation (prov:Agent)
|
||||
reason: Optional human-readable reason
|
||||
metadata: Optional metadata to merge into the entry
|
||||
|
||||
Returns:
|
||||
The updated (invalidated) ProvenanceEntry
|
||||
|
||||
Raises:
|
||||
ValueError: If no provenance entry exists for entity_id
|
||||
|
||||
Example:
|
||||
>>> prov_mgr.invalidate("entity_1", agent_id="reviewer_jane",
|
||||
... reason="Source document retracted")
|
||||
"""
|
||||
with self.storage.transaction() as conn:
|
||||
existing = self.storage._retrieve_with_conn(conn, entity_id)
|
||||
if existing is None:
|
||||
raise ValueError(
|
||||
f"Cannot invalidate: no provenance entry found for entity_id={entity_id!r}"
|
||||
)
|
||||
|
||||
# Archive the pre-invalidation state under a stable key — a pure
|
||||
# relabel (see track_entity's identical pattern), so its
|
||||
# checksum/sequence_id/previous_checksum are left untouched.
|
||||
history_entry = copy.deepcopy(existing)
|
||||
base_history_id = f"{entity_id}:v:{existing.last_updated}"
|
||||
history_id = base_history_id
|
||||
counter = 1
|
||||
while self.storage._retrieve_with_conn(conn, history_id):
|
||||
history_id = f"{base_history_id}:{counter}"
|
||||
counter += 1
|
||||
history_entry.entity_id = history_id
|
||||
self.storage._store_with_conn(conn, history_entry)
|
||||
|
||||
entry = copy.deepcopy(existing)
|
||||
entry.invalidated = True
|
||||
entry.invalidated_at_time = datetime.utcnow().isoformat()
|
||||
entry.invalidated_by = agent_id
|
||||
entry.invalidation_reason = reason
|
||||
entry.previous_version_id = history_id
|
||||
if metadata:
|
||||
entry.metadata = {**entry.metadata, **metadata}
|
||||
|
||||
self._save_entry(entry, _conn=conn, _raise_on_error=True)
|
||||
|
||||
return entry
|
||||
|
||||
# === Utility Methods ===
|
||||
|
||||
def clear(self) -> int:
|
||||
"""
|
||||
Clear all provenance data.
|
||||
|
||||
Note: this is a bulk storage reset (used for dev/test teardown), not
|
||||
a single-fact retraction — use invalidate() to retract/correct an
|
||||
individual tracked entity while preserving its audit trail.
|
||||
|
||||
Returns:
|
||||
Number of entries cleared
|
||||
"""
|
||||
@@ -801,28 +1192,44 @@ class ProvenanceManager:
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
def export_prov(self, format: str = "turtle") -> str:
|
||||
# Agent-type refinement for export_prov (issue #825, Part A item 6 —
|
||||
# cheap, opportunistic PROV-O typing on top of the generic prov:Agent).
|
||||
_AGENT_TYPE_PROV_CLASS = {
|
||||
"person": "Person",
|
||||
"software_agent": "SoftwareAgent",
|
||||
"organization": "Organization",
|
||||
}
|
||||
|
||||
def export_prov(self, format: str = "turtle", base_uri: Optional[str] = None) -> str:
|
||||
"""
|
||||
Export provenance as W3C PROV-O RDF.
|
||||
|
||||
Args:
|
||||
format: RDF format ('turtle', 'ntriples', 'jsonld')
|
||||
base_uri: Namespace URI entities/agents/activities are minted
|
||||
under (issue #825, Part B Tier 3). Defaults to
|
||||
DEFAULT_BASE_URI, which matches RDFExporter's NamespaceManager
|
||||
"semantica" entry so KG-exported and PROV-exported URIs for
|
||||
the same entity_id co-resolve.
|
||||
|
||||
Returns:
|
||||
Serialized RDF string
|
||||
"""
|
||||
from rdflib import Graph, Literal, Namespace, URIRef
|
||||
from rdflib import BNode, Graph, Literal, Namespace, URIRef
|
||||
from rdflib.namespace import RDF, XSD
|
||||
|
||||
PROV = Namespace("http://www.w3.org/ns/prov#")
|
||||
EX = Namespace("http://example.org/ns/")
|
||||
EX = Namespace(base_uri or DEFAULT_BASE_URI)
|
||||
|
||||
g = Graph()
|
||||
g.bind("prov", PROV)
|
||||
g.bind("ex", EX)
|
||||
|
||||
def uri(entity_id: Any) -> URIRef:
|
||||
return URIRef(EX[str(entity_id)])
|
||||
|
||||
for e in self.storage.retrieve_all():
|
||||
ent_uri = URIRef(EX[str(e.entity_id)])
|
||||
ent_uri = uri(e.entity_id)
|
||||
g.add((ent_uri, RDF.type, PROV.Entity))
|
||||
|
||||
if getattr(e, "timestamp", None):
|
||||
@@ -834,29 +1241,125 @@ class ProvenanceManager:
|
||||
)
|
||||
)
|
||||
|
||||
ag_uri = None
|
||||
if getattr(e, "agent_id", None) and e.agent_id != "unknown":
|
||||
ag_uri = URIRef(EX[str(e.agent_id)])
|
||||
ag_uri = uri(e.agent_id)
|
||||
g.add((ag_uri, RDF.type, PROV.Agent))
|
||||
prov_subclass = self._AGENT_TYPE_PROV_CLASS.get(
|
||||
getattr(e, "agent_type", None)
|
||||
)
|
||||
if prov_subclass:
|
||||
g.add((ag_uri, RDF.type, PROV[prov_subclass]))
|
||||
g.add((ent_uri, PROV.wasAttributedTo, ag_uri))
|
||||
|
||||
# Qualified Association with hadRole (issue #825, Part A item
|
||||
# 6): distinguishes "approved by" from "generated by" from
|
||||
# "reviewed by" for the same agent/entity pair, which the
|
||||
# plain wasAttributedTo triple above cannot express.
|
||||
association = BNode()
|
||||
g.add((ent_uri, PROV.qualifiedAssociation, association))
|
||||
g.add((association, RDF.type, PROV.Association))
|
||||
g.add((association, PROV.agent, ag_uri))
|
||||
role = getattr(e, "role", None) or "generator"
|
||||
g.add((association, PROV.hadRole, uri(f"role_{role}")))
|
||||
|
||||
# prov:actedOnBehalfOf (issue #825, Part B Tier 2) — agent
|
||||
# delegation, e.g. an automated agent acting on behalf of the
|
||||
# human/organization that authorized it.
|
||||
delegate_id = getattr(e, "acted_on_behalf_of", None)
|
||||
if delegate_id:
|
||||
delegate_uri = uri(delegate_id)
|
||||
g.add((delegate_uri, RDF.type, PROV.Agent))
|
||||
g.add((ag_uri, PROV.actedOnBehalfOf, delegate_uri))
|
||||
|
||||
act_uri = None
|
||||
if getattr(e, "activity_id", None) and e.activity_id != "unknown":
|
||||
act_uri = URIRef(EX[str(e.activity_id)])
|
||||
act_uri = uri(e.activity_id)
|
||||
g.add((act_uri, RDF.type, PROV.Activity))
|
||||
g.add((ent_uri, PROV.wasGeneratedBy, act_uri))
|
||||
|
||||
# Typed Activity timing (issue #825, Part B Tier 1)
|
||||
if getattr(e, "activity_started_at_time", None):
|
||||
g.add((act_uri, PROV.startedAtTime,
|
||||
Literal(e.activity_started_at_time, datatype=XSD.dateTime)))
|
||||
if getattr(e, "activity_ended_at_time", None):
|
||||
g.add((act_uri, PROV.endedAtTime,
|
||||
Literal(e.activity_ended_at_time, datatype=XSD.dateTime)))
|
||||
|
||||
# Qualified Generation (issue #825, Part B Tier 1)
|
||||
generation = BNode()
|
||||
g.add((ent_uri, PROV.qualifiedGeneration, generation))
|
||||
g.add((generation, RDF.type, PROV.Generation))
|
||||
g.add((generation, PROV.activity, act_uri))
|
||||
if getattr(e, "timestamp", None):
|
||||
g.add((generation, PROV.atTime, Literal(e.timestamp, datatype=XSD.dateTime)))
|
||||
|
||||
# prov:wasAssociatedWith (issue #825, Part B Tier 2) — direct
|
||||
# Activity->Agent link, distinct from the Entity->Agent
|
||||
# wasAttributedTo/qualifiedAssociation triples above.
|
||||
if ag_uri is not None:
|
||||
g.add((act_uri, PROV.wasAssociatedWith, ag_uri))
|
||||
|
||||
# prov:wasInformedBy (issue #825, Part B Tier 2) — chains this
|
||||
# activity to prior activities it was informed by (e.g. a
|
||||
# pipeline stage informed by the stage before it).
|
||||
for informing_id in getattr(e, "informed_by_activities", []):
|
||||
g.add((act_uri, PROV.wasInformedBy, uri(informing_id)))
|
||||
|
||||
def emit_derivation(source_id: Any) -> None:
|
||||
"""Emit plain + qualified wasDerivedFrom for a source entity."""
|
||||
s_uri = uri(source_id)
|
||||
g.add((ent_uri, PROV.wasDerivedFrom, s_uri))
|
||||
derivation = BNode()
|
||||
g.add((ent_uri, PROV.qualifiedDerivation, derivation))
|
||||
g.add((derivation, RDF.type, PROV.Derivation))
|
||||
g.add((derivation, PROV.entity, s_uri))
|
||||
if act_uri is not None:
|
||||
g.add((derivation, PROV.hadActivity, act_uri))
|
||||
|
||||
if getattr(e, "parent_entity_id", None):
|
||||
p_uri = URIRef(EX[str(e.parent_entity_id)])
|
||||
g.add((ent_uri, PROV.wasDerivedFrom, p_uri))
|
||||
emit_derivation(e.parent_entity_id)
|
||||
|
||||
for u_id in getattr(e, "used_entities", []):
|
||||
u_uri = URIRef(EX[str(u_id)])
|
||||
u_uri = uri(u_id)
|
||||
# Emit wasDerivedFrom only when this used entity is not the same
|
||||
# as parent_entity_id — which already carries that triple above.
|
||||
if u_id != getattr(e, "parent_entity_id", None):
|
||||
g.add((ent_uri, PROV.wasDerivedFrom, u_uri))
|
||||
if getattr(e, "activity_id", None) and e.activity_id != "unknown":
|
||||
act_uri = URIRef(EX[str(e.activity_id)])
|
||||
emit_derivation(u_id)
|
||||
if act_uri is not None:
|
||||
g.add((act_uri, PROV.used, u_uri))
|
||||
# Qualified Usage (issue #825, Part B Tier 1)
|
||||
usage = BNode()
|
||||
g.add((act_uri, PROV.qualifiedUsage, usage))
|
||||
g.add((usage, RDF.type, PROV.Usage))
|
||||
g.add((usage, PROV.entity, u_uri))
|
||||
|
||||
# Qualified Invalidation (issue #825, Part A item 1): records the
|
||||
# tombstone as provable RDF rather than a silent hard delete.
|
||||
if getattr(e, "invalidated", False):
|
||||
invalidation = BNode()
|
||||
g.add((ent_uri, PROV.qualifiedInvalidation, invalidation))
|
||||
g.add((invalidation, RDF.type, PROV.Invalidation))
|
||||
if getattr(e, "invalidated_at_time", None):
|
||||
g.add(
|
||||
(
|
||||
invalidation,
|
||||
PROV.invalidatedAtTime,
|
||||
Literal(e.invalidated_at_time, datatype=XSD.dateTime),
|
||||
)
|
||||
)
|
||||
if getattr(e, "invalidated_by", None):
|
||||
inv_ag_uri = uri(e.invalidated_by)
|
||||
g.add((inv_ag_uri, RDF.type, PROV.Agent))
|
||||
g.add((invalidation, PROV.agent, inv_ag_uri))
|
||||
|
||||
# prov:Collection / prov:Bundle membership (issue #825, Part B
|
||||
# Tier 3) — partitions provenance by source/dataset/ingestion-run.
|
||||
# Membership triples, not true RDF named-graph partitioning.
|
||||
if getattr(e, "bundle_id", None):
|
||||
bundle_uri = uri(f"bundle_{e.bundle_id}")
|
||||
g.add((bundle_uri, RDF.type, PROV.Bundle))
|
||||
g.add((bundle_uri, PROV.hadMember, ent_uri))
|
||||
|
||||
rdf_format = "json-ld" if format == "jsonld" else format
|
||||
return g.serialize(format=rdf_format)
|
||||
@@ -873,6 +1376,7 @@ class ProvenanceManager:
|
||||
"""
|
||||
entries = self.storage.retrieve_all()
|
||||
all_ids = {e.entity_id for e in entries}
|
||||
all_activity_ids = {e.activity_id for e in entries if getattr(e, "activity_id", None)}
|
||||
|
||||
missing_refs = []
|
||||
for e in entries:
|
||||
@@ -882,15 +1386,77 @@ class ProvenanceManager:
|
||||
for u_id in getattr(e, "used_entities", []):
|
||||
if u_id not in all_ids:
|
||||
missing_refs.append(f"{e.entity_id} -> {u_id}")
|
||||
for ref_id in (
|
||||
getattr(e, "previous_version_id", None),
|
||||
getattr(e, "derived_from_id", None),
|
||||
getattr(e, "supersedes", None),
|
||||
):
|
||||
if ref_id and ref_id not in all_ids:
|
||||
missing_refs.append(f"{e.entity_id} -> {ref_id}")
|
||||
# informed_by_activities references activity_ids, a distinct
|
||||
# ID space from entity_id (issue #825, Part B Tier 2).
|
||||
for act_id in getattr(e, "informed_by_activities", []):
|
||||
if act_id not in all_activity_ids:
|
||||
missing_refs.append(f"{e.entity_id} (activity) -> {act_id}")
|
||||
|
||||
valid = len(missing_refs) == 0
|
||||
errors = len(missing_refs)
|
||||
invalidated_count = sum(1 for e in entries if getattr(e, "invalidated", False))
|
||||
|
||||
return {
|
||||
"valid": valid,
|
||||
"total_entries": len(entries),
|
||||
"missing_references": missing_refs,
|
||||
"invalidated_count": invalidated_count,
|
||||
"strict": strict,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
def verify_chain(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Verify the hash chain across all provenance entries (issue #825,
|
||||
Part A item 2).
|
||||
|
||||
Sorts entries by sequence_id (global insertion order) and checks
|
||||
both that each entry's own checksum matches its content, and that
|
||||
each entry's previous_checksum matches the checksum of the entry
|
||||
that precedes it. A gap in the chain — a surviving entry whose
|
||||
previous_checksum doesn't match its predecessor's checksum — is
|
||||
exactly what wholesale row deletion produces, since per-row
|
||||
checksums alone can't detect that a row is simply missing.
|
||||
|
||||
Returns:
|
||||
Dictionary with "valid", "total_entries", and "broken_links"
|
||||
(list of {"entity_id", "sequence_id", "reason"} dicts).
|
||||
"""
|
||||
entries = sorted(
|
||||
(e for e in self.storage.retrieve_all() if e.sequence_id is not None),
|
||||
key=lambda e: e.sequence_id,
|
||||
)
|
||||
|
||||
broken_links: List[Dict[str, Any]] = []
|
||||
expected_previous: Optional[str] = None
|
||||
for entry in entries:
|
||||
if not verify_checksum(entry):
|
||||
broken_links.append({
|
||||
"entity_id": entry.entity_id,
|
||||
"sequence_id": entry.sequence_id,
|
||||
"reason": "checksum_mismatch",
|
||||
})
|
||||
continue
|
||||
if entry.previous_checksum != expected_previous:
|
||||
broken_links.append({
|
||||
"entity_id": entry.entity_id,
|
||||
"sequence_id": entry.sequence_id,
|
||||
"reason": "chain_break",
|
||||
"expected_previous_checksum": expected_previous,
|
||||
"actual_previous_checksum": entry.previous_checksum,
|
||||
})
|
||||
expected_previous = entry.checksum
|
||||
|
||||
return {
|
||||
"valid": len(broken_links) == 0,
|
||||
"total_entries": len(entries),
|
||||
"broken_links": broken_links,
|
||||
}
|
||||
|
||||
|
||||
@@ -13,10 +13,14 @@ Consolidates:
|
||||
W3C PROV-O Mapping:
|
||||
- ProvenanceEntry.entity_id → prov:Entity
|
||||
- ProvenanceEntry.activity_id → prov:Activity
|
||||
- ProvenanceEntry.agent_id → prov:Agent
|
||||
- ProvenanceEntry.parent_entity_id → prov:wasDerivedFrom
|
||||
- ProvenanceEntry.agent_id / agent_type → prov:Agent (Person|SoftwareAgent|Organization)
|
||||
- ProvenanceEntry.role → prov:hadRole (via prov:qualifiedAssociation)
|
||||
- ProvenanceEntry.parent_entity_id → prov:wasDerivedFrom (legacy combined field)
|
||||
- ProvenanceEntry.derived_from_id → prov:wasDerivedFrom (true cross-source derivation)
|
||||
- ProvenanceEntry.previous_version_id → prior version of the same fact (correction/versioning)
|
||||
- ProvenanceEntry.used_entities → prov:used
|
||||
- ProvenanceEntry.timestamp → prov:generatedAtTime
|
||||
- ProvenanceEntry.invalidated* → prov:Invalidation (tombstone, not a hard delete)
|
||||
|
||||
Author: Semantica Contributors
|
||||
License: MIT
|
||||
@@ -76,6 +80,11 @@ class ProvenanceEntry:
|
||||
activity_id: str
|
||||
agent_id: str = "semantica"
|
||||
|
||||
# Accountability-scoped agent typing (issue #825, Part A item 3)
|
||||
agent_type: str = "software_agent" # "person" | "software_agent" | "organization"
|
||||
is_automated: bool = True
|
||||
role: Optional[str] = None # prov:hadRole, e.g. "generator", "approver", "reviewer"
|
||||
|
||||
# Audit-grade source tracking
|
||||
source_document: str = ""
|
||||
source_location: Optional[str] = None
|
||||
@@ -90,10 +99,58 @@ class ProvenanceEntry:
|
||||
confidence: float = 1.0
|
||||
checksum: Optional[str] = None
|
||||
|
||||
# Chain of custody (W3C PROV-O)
|
||||
# Hash-chain linkage (issue #825, Part A item 2): previous_checksum links
|
||||
# this entry to the prior entry in global insertion order (sequence_id),
|
||||
# so wholesale row deletion breaks the chain and becomes detectable via
|
||||
# ProvenanceManager.verify_chain().
|
||||
sequence_id: Optional[int] = None
|
||||
previous_checksum: Optional[str] = None
|
||||
|
||||
# Chain of custody (W3C PROV-O) — kept for backward compatibility.
|
||||
parent_entity_id: Optional[str] = None
|
||||
used_entities: List[str] = field(default_factory=list)
|
||||
|
||||
# Versioning vs. derivation (issue #825, Part A item 4): previous_version_id
|
||||
# is "this corrects/replaces a prior version of the same fact"
|
||||
# (prov:specializationOf-flavored); derived_from_id is "this fact was
|
||||
# derived from a different source entity" (prov:wasDerivedFrom). Both are
|
||||
# additive alongside parent_entity_id, which remains the legacy combined
|
||||
# field for existing readers.
|
||||
previous_version_id: Optional[str] = None
|
||||
derived_from_id: Optional[str] = None
|
||||
|
||||
# Typed Activity (issue #825, Part B Tier 1): prov:startedAtTime/endedAtTime
|
||||
# for the activity that generated this entry.
|
||||
activity_started_at_time: Optional[str] = None
|
||||
activity_ended_at_time: Optional[str] = None
|
||||
|
||||
# prov:actedOnBehalfOf / prov:wasInformedBy (issue #825, Part B Tier 2)
|
||||
acted_on_behalf_of: Optional[str] = None
|
||||
informed_by_activities: List[str] = field(default_factory=list)
|
||||
|
||||
# Bitemporal fields merged from the deprecated kg.ProvenanceTracker
|
||||
# (issue #825, Part B Tier 3) — in the deprecated tracker these were
|
||||
# always caller-supplied optional metadata keys, never auto-computed;
|
||||
# same contract here. valid_from/valid_until express the fact's own
|
||||
# asserted validity window (domain/business time), independent of
|
||||
# sequence_id/timestamp which track when the system recorded it.
|
||||
valid_from: Optional[str] = None
|
||||
valid_until: Optional[str] = None
|
||||
revision_type: Optional[str] = None
|
||||
supersedes: Optional[str] = None
|
||||
|
||||
# prov:Collection / prov:Bundle membership (issue #825, Part B Tier 3):
|
||||
# partitions provenance by source/dataset/ingestion-run.
|
||||
bundle_id: Optional[str] = None
|
||||
|
||||
# Invalidation / tombstone tracking (issue #825, Part A item 1): set via
|
||||
# ProvenanceManager.invalidate() instead of deleting the row, so an audit
|
||||
# can prove a fact existed, was reviewed, and was retracted/corrected.
|
||||
invalidated: bool = False
|
||||
invalidated_at_time: Optional[str] = None
|
||||
invalidated_by: Optional[str] = None
|
||||
invalidation_reason: Optional[str] = None
|
||||
|
||||
# Chunk-specific fields (from split.ProvenanceInfo)
|
||||
start_index: Optional[int] = None
|
||||
end_index: Optional[int] = None
|
||||
@@ -117,6 +174,9 @@ class ProvenanceEntry:
|
||||
"entity_type": self.entity_type,
|
||||
"activity_id": self.activity_id,
|
||||
"agent_id": self.agent_id,
|
||||
"agent_type": self.agent_type,
|
||||
"is_automated": self.is_automated,
|
||||
"role": self.role,
|
||||
"source_document": self.source_document,
|
||||
"source_location": self.source_location,
|
||||
"source_quote": self.source_quote,
|
||||
@@ -125,8 +185,25 @@ class ProvenanceEntry:
|
||||
"last_updated": self.last_updated,
|
||||
"confidence": self.confidence,
|
||||
"checksum": self.checksum,
|
||||
"sequence_id": self.sequence_id,
|
||||
"previous_checksum": self.previous_checksum,
|
||||
"parent_entity_id": self.parent_entity_id,
|
||||
"used_entities": self.used_entities,
|
||||
"previous_version_id": self.previous_version_id,
|
||||
"derived_from_id": self.derived_from_id,
|
||||
"activity_started_at_time": self.activity_started_at_time,
|
||||
"activity_ended_at_time": self.activity_ended_at_time,
|
||||
"acted_on_behalf_of": self.acted_on_behalf_of,
|
||||
"informed_by_activities": self.informed_by_activities,
|
||||
"valid_from": self.valid_from,
|
||||
"valid_until": self.valid_until,
|
||||
"revision_type": self.revision_type,
|
||||
"supersedes": self.supersedes,
|
||||
"bundle_id": self.bundle_id,
|
||||
"invalidated": self.invalidated,
|
||||
"invalidated_at_time": self.invalidated_at_time,
|
||||
"invalidated_by": self.invalidated_by,
|
||||
"invalidation_reason": self.invalidation_reason,
|
||||
"start_index": self.start_index,
|
||||
"end_index": self.end_index,
|
||||
"credibility": self.credibility,
|
||||
@@ -275,3 +352,144 @@ class PropertySource:
|
||||
for s in data["sources"]
|
||||
]
|
||||
return cls(**data)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentRecord:
|
||||
"""
|
||||
Minimum-viable typed W3C PROV-O agent (prov:Agent).
|
||||
|
||||
Narrower than the full PROV-O Person/SoftwareAgent/Organization taxonomy —
|
||||
just enough to answer "was a human accountable here" for high-stakes
|
||||
provenance review. Pass an AgentRecord to ProvenanceManager.track_entity()
|
||||
(etc.) via the `agent=` kwarg to set agent_id/agent_type/is_automated
|
||||
together.
|
||||
|
||||
Attributes:
|
||||
id: Agent identifier (prov:Agent)
|
||||
agent_type: One of "person", "software_agent", "organization"
|
||||
is_automated: Whether this agent acted without direct human review
|
||||
name: Optional human-readable name
|
||||
metadata: Additional metadata dictionary
|
||||
|
||||
Example:
|
||||
>>> agent = AgentRecord(id="reviewer_jane", agent_type="person", is_automated=False)
|
||||
>>> prov_mgr.track_entity("entity_1", source="doc_1", agent=agent, role="approver")
|
||||
"""
|
||||
|
||||
id: str
|
||||
agent_type: str = "software_agent"
|
||||
is_automated: bool = True
|
||||
name: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert agent record to dictionary."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"agent_type": self.agent_type,
|
||||
"is_automated": self.is_automated,
|
||||
"name": self.name,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "AgentRecord":
|
||||
"""Create agent record from dictionary."""
|
||||
return cls(**data)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActivityRecord:
|
||||
"""
|
||||
Minimum-viable typed W3C PROV-O activity (prov:Activity).
|
||||
|
||||
Issue #825, Part B Tier 1 — promotes activity_id from a bare string to
|
||||
a typed object carrying start/end timing, mirroring AgentRecord. Pass an
|
||||
ActivityRecord to ProvenanceManager.track_entity() (etc.) via the
|
||||
`activity=` kwarg to set activity_id/activity_started_at_time/
|
||||
activity_ended_at_time together.
|
||||
|
||||
Attributes:
|
||||
id: Activity identifier (prov:Activity)
|
||||
activity_type: Free-form activity classification (e.g. "process", "extraction")
|
||||
started_at_time: prov:startedAtTime, ISO datetime string
|
||||
ended_at_time: prov:endedAtTime, ISO datetime string
|
||||
metadata: Additional metadata dictionary
|
||||
|
||||
Example:
|
||||
>>> activity = ActivityRecord(
|
||||
... id="bureau_parsing_run_42",
|
||||
... started_at_time="2026-01-01T00:00:00",
|
||||
... ended_at_time="2026-01-01T00:00:03",
|
||||
... )
|
||||
>>> prov_mgr.track_entity("entity_1", source="doc_1", activity=activity)
|
||||
"""
|
||||
|
||||
id: str
|
||||
activity_type: str = "process"
|
||||
started_at_time: Optional[str] = None
|
||||
ended_at_time: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert activity record to dictionary."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"activity_type": self.activity_type,
|
||||
"started_at_time": self.started_at_time,
|
||||
"ended_at_time": self.ended_at_time,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "ActivityRecord":
|
||||
"""Create activity record from dictionary."""
|
||||
return cls(**data)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Invalidation:
|
||||
"""
|
||||
W3C PROV-O invalidation record (prov:Invalidation).
|
||||
|
||||
Represents the tombstone for a retracted/corrected provenance entry:
|
||||
"this was deleted/corrected, by whom, when, why" — recorded in place of
|
||||
a hard delete so the fact that an entity existed remains provable.
|
||||
|
||||
Attributes:
|
||||
entity_id: The entity that was invalidated (prov:Entity)
|
||||
invalidated_at_time: When invalidation occurred (prov:invalidatedAtTime)
|
||||
invalidated_by: Agent responsible for the invalidation (prov:Agent)
|
||||
reason: Optional human-readable reason
|
||||
metadata: Additional metadata dictionary
|
||||
|
||||
Example:
|
||||
>>> Invalidation(
|
||||
... entity_id="entity_123",
|
||||
... invalidated_at_time="2026-01-01T00:00:00",
|
||||
... invalidated_by="reviewer_jane",
|
||||
... reason="Source document retracted",
|
||||
... )
|
||||
"""
|
||||
|
||||
entity_id: str
|
||||
invalidated_at_time: str
|
||||
invalidated_by: str
|
||||
reason: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert invalidation record to dictionary."""
|
||||
return {
|
||||
"entity_id": self.entity_id,
|
||||
"invalidated_at_time": self.invalidated_at_time,
|
||||
"invalidated_by": self.invalidated_by,
|
||||
"reason": self.reason,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "Invalidation":
|
||||
"""Create invalidation record from dictionary."""
|
||||
return cls(**data)
|
||||
|
||||
@@ -20,7 +20,7 @@ License: MIT
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Optional, Dict, Any
|
||||
from typing import List, Optional, Dict, Any, Tuple
|
||||
import sqlite3
|
||||
import json
|
||||
import uuid
|
||||
@@ -123,6 +123,70 @@ class ProvenanceStorage(ABC):
|
||||
"""Internal retrieve method using an active connection/transaction."""
|
||||
return self.retrieve(entity_id)
|
||||
|
||||
def get_chain_head(self, conn: Any = None) -> Optional[Tuple[int, str]]:
|
||||
"""
|
||||
Return (sequence_id, checksum) of the most recently stored entry, or
|
||||
None if the store is empty or this backend doesn't support chaining.
|
||||
|
||||
Used by ProvenanceManager._save_entry to link each new entry to its
|
||||
predecessor (issue #825, Part A item 2 — hash-chained integrity).
|
||||
Default implementation reports no chaining support; override in
|
||||
backends that maintain sequence_id.
|
||||
"""
|
||||
return None
|
||||
|
||||
def trace_descendants(
|
||||
self, entity_id: str, max_depth: Optional[int] = None
|
||||
) -> List[ProvenanceEntry]:
|
||||
"""
|
||||
Trace downstream descendants (reverse lineage) of an entity.
|
||||
|
||||
Generic default implemented purely against retrieve_all(): builds a
|
||||
reverse adjacency map from parent_entity_id/previous_version_id/
|
||||
derived_from_id/used_entities, then BFS from entity_id. Correct for
|
||||
any backend; SQLiteStorage overrides this with indexed queries.
|
||||
|
||||
Args:
|
||||
entity_id: Entity identifier to find descendants of
|
||||
max_depth: Optional maximum BFS depth
|
||||
|
||||
Returns:
|
||||
List of ProvenanceEntry objects that (transitively) reference
|
||||
entity_id, in BFS order.
|
||||
"""
|
||||
reverse_index: Dict[str, List[ProvenanceEntry]] = {}
|
||||
for entry in self.retrieve_all():
|
||||
referenced = set(
|
||||
filter(
|
||||
None,
|
||||
[
|
||||
entry.parent_entity_id,
|
||||
entry.previous_version_id,
|
||||
entry.derived_from_id,
|
||||
],
|
||||
)
|
||||
)
|
||||
referenced.update(entry.used_entities or [])
|
||||
for ref_id in referenced:
|
||||
reverse_index.setdefault(ref_id, []).append(entry)
|
||||
|
||||
descendants = []
|
||||
visited = {entity_id}
|
||||
queue = deque([(entity_id, 0)])
|
||||
|
||||
while queue:
|
||||
current_id, depth = queue.popleft()
|
||||
if max_depth is not None and depth >= max_depth:
|
||||
continue
|
||||
for child in reverse_index.get(current_id, []):
|
||||
if child.entity_id in visited:
|
||||
continue
|
||||
visited.add(child.entity_id)
|
||||
descendants.append(child)
|
||||
queue.append((child.entity_id, depth + 1))
|
||||
|
||||
return descendants
|
||||
|
||||
|
||||
class InMemoryStorage(ProvenanceStorage):
|
||||
"""
|
||||
@@ -150,6 +214,11 @@ class InMemoryStorage(ProvenanceStorage):
|
||||
"""Initialize in-memory storage."""
|
||||
self._entries: Dict[str, ProvenanceEntry] = {}
|
||||
self._local = threading.local()
|
||||
# Hash-chain state (issue #825, Part A item 2). Best-effort: this is
|
||||
# the dev/test backend, not the durable audit backend (SQLiteStorage).
|
||||
self._seq_lock = threading.Lock()
|
||||
self._seq_counter = 0
|
||||
self._chain_head: Optional[str] = None
|
||||
|
||||
def _get_pending_stack(self) -> list:
|
||||
if not hasattr(self._local, "pending_stack"):
|
||||
@@ -163,6 +232,10 @@ class InMemoryStorage(ProvenanceStorage):
|
||||
Args:
|
||||
entry: ProvenanceEntry to store
|
||||
"""
|
||||
with self._seq_lock:
|
||||
if entry.sequence_id is not None:
|
||||
self._seq_counter = max(self._seq_counter, entry.sequence_id)
|
||||
self._chain_head = entry.checksum
|
||||
self._entries[entry.entity_id] = entry
|
||||
|
||||
def retrieve(self, entity_id: str) -> Optional[ProvenanceEntry]:
|
||||
@@ -286,6 +359,31 @@ class InMemoryStorage(ProvenanceStorage):
|
||||
return pending[entity_id]
|
||||
return self.retrieve(entity_id)
|
||||
|
||||
def get_chain_head(self, conn: Any = None) -> Optional[Tuple[int, str]]:
|
||||
"""Return (sequence_id, checksum) of the most recent entry, including
|
||||
entries staged but not yet committed within the current transaction.
|
||||
|
||||
Must compare against the *committed* head too, not just the pending
|
||||
stack: a transaction can stage an unchanged relabel (track_entity's
|
||||
archival, or invalidate()'s pre-invalidation archive) whose
|
||||
sequence_id is older than the true head from a prior, already-
|
||||
committed transaction. Blindly preferring "last staged entry"
|
||||
understates the head and corrupts the chain for the next append.
|
||||
"""
|
||||
with self._seq_lock:
|
||||
best_seq = self._seq_counter
|
||||
best_checksum = self._chain_head
|
||||
for pending in self._get_pending_stack():
|
||||
for staged_entry in pending.values():
|
||||
if staged_entry.sequence_id is not None and (
|
||||
best_checksum is None or staged_entry.sequence_id > best_seq
|
||||
):
|
||||
best_seq = staged_entry.sequence_id
|
||||
best_checksum = staged_entry.checksum
|
||||
if best_checksum is None:
|
||||
return None
|
||||
return (best_seq, best_checksum)
|
||||
|
||||
|
||||
class SQLiteStorage(ProvenanceStorage):
|
||||
"""
|
||||
@@ -461,7 +559,27 @@ class SQLiteStorage(ProvenanceStorage):
|
||||
end_index INTEGER,
|
||||
credibility REAL,
|
||||
metadata TEXT,
|
||||
version TEXT DEFAULT '1.0'
|
||||
version TEXT DEFAULT '1.0',
|
||||
agent_type TEXT DEFAULT 'software_agent',
|
||||
is_automated INTEGER DEFAULT 1,
|
||||
role TEXT,
|
||||
sequence_id INTEGER,
|
||||
previous_checksum TEXT,
|
||||
previous_version_id TEXT,
|
||||
derived_from_id TEXT,
|
||||
invalidated INTEGER DEFAULT 0,
|
||||
invalidated_at_time TEXT,
|
||||
invalidated_by TEXT,
|
||||
invalidation_reason TEXT,
|
||||
activity_started_at_time TEXT,
|
||||
activity_ended_at_time TEXT,
|
||||
acted_on_behalf_of TEXT,
|
||||
informed_by_activities TEXT,
|
||||
valid_from TEXT,
|
||||
valid_until TEXT,
|
||||
revision_type TEXT,
|
||||
supersedes TEXT,
|
||||
bundle_id TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
@@ -481,6 +599,31 @@ class SQLiteStorage(ProvenanceStorage):
|
||||
ON provenance(parent_entity_id)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_previous_version_id
|
||||
ON provenance(previous_version_id)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_derived_from_id
|
||||
ON provenance(derived_from_id)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_sequence_id
|
||||
ON provenance(sequence_id)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_invalidated
|
||||
ON provenance(invalidated)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_bundle_id
|
||||
ON provenance(bundle_id)
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -500,7 +643,9 @@ class SQLiteStorage(ProvenanceStorage):
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO provenance VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
""", (
|
||||
entry.entity_id,
|
||||
@@ -521,7 +666,27 @@ class SQLiteStorage(ProvenanceStorage):
|
||||
entry.end_index,
|
||||
entry.credibility,
|
||||
json.dumps(entry.metadata),
|
||||
entry.version
|
||||
entry.version,
|
||||
entry.agent_type,
|
||||
1 if entry.is_automated else 0,
|
||||
entry.role,
|
||||
entry.sequence_id,
|
||||
entry.previous_checksum,
|
||||
entry.previous_version_id,
|
||||
entry.derived_from_id,
|
||||
1 if entry.invalidated else 0,
|
||||
entry.invalidated_at_time,
|
||||
entry.invalidated_by,
|
||||
entry.invalidation_reason,
|
||||
entry.activity_started_at_time,
|
||||
entry.activity_ended_at_time,
|
||||
entry.acted_on_behalf_of,
|
||||
json.dumps(entry.informed_by_activities),
|
||||
entry.valid_from,
|
||||
entry.valid_until,
|
||||
entry.revision_type,
|
||||
entry.supersedes,
|
||||
entry.bundle_id,
|
||||
))
|
||||
|
||||
def retrieve(self, entity_id: str) -> Optional[ProvenanceEntry]:
|
||||
@@ -637,6 +802,120 @@ class SQLiteStorage(ProvenanceStorage):
|
||||
|
||||
return lineage
|
||||
|
||||
def get_chain_head(self, conn: Any = None) -> Optional[Tuple[int, str]]:
|
||||
"""Return (sequence_id, checksum) of the most recently stored entry.
|
||||
|
||||
Reuses the caller's transaction connection when given, so the head
|
||||
read and the subsequent insert happen atomically under the existing
|
||||
BEGIN IMMEDIATE writer lock (issue #825, Part A item 2).
|
||||
"""
|
||||
# Tie-break on rowid (SQLite's implicit row-order column) in addition
|
||||
# to sequence_id: during track_entity's archival, the just-relabeled
|
||||
# history row briefly shares its sequence_id with the still-present
|
||||
# (about-to-be-overwritten) live row it was copied from. rowid DESC
|
||||
# deterministically picks the most-recently-written row of the two,
|
||||
# which is the correct chain head — relying on sequence_id ordering
|
||||
# alone leaves the tie-break implementation-defined.
|
||||
query = (
|
||||
"SELECT sequence_id, checksum FROM provenance "
|
||||
"WHERE sequence_id IS NOT NULL "
|
||||
"ORDER BY sequence_id DESC, rowid DESC LIMIT 1"
|
||||
)
|
||||
if conn is not None:
|
||||
row = conn.cursor().execute(query).fetchone()
|
||||
return (row[0], row[1]) if row else None
|
||||
with self._read_connection() as read_conn:
|
||||
row = read_conn.cursor().execute(query).fetchone()
|
||||
return (row[0], row[1]) if row else None
|
||||
|
||||
def trace_descendants(
|
||||
self, entity_id: str, max_depth: Optional[int] = None
|
||||
) -> List[ProvenanceEntry]:
|
||||
"""
|
||||
Trace downstream descendants (reverse lineage) using indexed lookups
|
||||
on parent_entity_id/previous_version_id/derived_from_id, plus an
|
||||
in-memory reverse index over used_entities (a JSON column, so not
|
||||
directly indexable in SQLite).
|
||||
|
||||
Args:
|
||||
entity_id: Entity identifier to find descendants of
|
||||
max_depth: Optional maximum BFS depth
|
||||
|
||||
Returns:
|
||||
List of ProvenanceEntry objects that (transitively) reference
|
||||
entity_id, in BFS order.
|
||||
"""
|
||||
descendants: List[ProvenanceEntry] = []
|
||||
visited = {entity_id}
|
||||
frontier = [entity_id]
|
||||
depth = 0
|
||||
|
||||
with self._read_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"SELECT entity_id, used_entities FROM provenance "
|
||||
"WHERE used_entities IS NOT NULL AND used_entities != '[]'"
|
||||
)
|
||||
used_reverse: Dict[str, List[str]] = {}
|
||||
for child_id, used_json in cursor.fetchall():
|
||||
try:
|
||||
used_ids = json.loads(used_json) if used_json else []
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
used_ids = []
|
||||
for uid in used_ids:
|
||||
used_reverse.setdefault(uid, []).append(child_id)
|
||||
|
||||
while frontier and (max_depth is None or depth < max_depth):
|
||||
current_frontier = list(dict.fromkeys(frontier))
|
||||
if not current_frontier:
|
||||
break
|
||||
|
||||
child_ids = set()
|
||||
for i in range(0, len(current_frontier), 999):
|
||||
chunk = current_frontier[i : i + 999]
|
||||
placeholders = ",".join("?" * len(chunk))
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT entity_id FROM provenance
|
||||
WHERE parent_entity_id IN ({placeholders})
|
||||
OR previous_version_id IN ({placeholders})
|
||||
OR derived_from_id IN ({placeholders})
|
||||
""",
|
||||
chunk * 3,
|
||||
)
|
||||
child_ids.update(row[0] for row in cursor.fetchall())
|
||||
|
||||
for ref_id in current_frontier:
|
||||
child_ids.update(used_reverse.get(ref_id, []))
|
||||
|
||||
next_frontier = [cid for cid in child_ids if cid not in visited]
|
||||
if not next_frontier:
|
||||
break
|
||||
visited.update(next_frontier)
|
||||
|
||||
entries_map = {}
|
||||
for i in range(0, len(next_frontier), 999):
|
||||
chunk = next_frontier[i : i + 999]
|
||||
placeholders = ",".join("?" * len(chunk))
|
||||
cursor.execute(
|
||||
f"SELECT * FROM provenance WHERE entity_id IN ({placeholders})",
|
||||
chunk,
|
||||
)
|
||||
for row in cursor.fetchall():
|
||||
entry = self._row_to_entry(row)
|
||||
entries_map[entry.entity_id] = entry
|
||||
|
||||
for cid in next_frontier:
|
||||
entry = entries_map.get(cid)
|
||||
if entry:
|
||||
descendants.append(entry)
|
||||
|
||||
frontier = next_frontier
|
||||
depth += 1
|
||||
|
||||
return descendants
|
||||
|
||||
def clear(self) -> int:
|
||||
"""
|
||||
Clear all provenance data.
|
||||
@@ -687,5 +966,25 @@ class SQLiteStorage(ProvenanceStorage):
|
||||
end_index=row[15],
|
||||
credibility=row[16],
|
||||
metadata=json.loads(row[17]) if row[17] else {},
|
||||
version=row[18]
|
||||
version=row[18],
|
||||
agent_type=row[19] or "software_agent",
|
||||
is_automated=bool(row[20]) if row[20] is not None else True,
|
||||
role=row[21],
|
||||
sequence_id=row[22],
|
||||
previous_checksum=row[23],
|
||||
previous_version_id=row[24],
|
||||
derived_from_id=row[25],
|
||||
invalidated=bool(row[26]) if row[26] is not None else False,
|
||||
invalidated_at_time=row[27],
|
||||
invalidated_by=row[28],
|
||||
invalidation_reason=row[29],
|
||||
activity_started_at_time=row[30],
|
||||
activity_ended_at_time=row[31],
|
||||
acted_on_behalf_of=row[32],
|
||||
informed_by_activities=json.loads(row[33]) if row[33] else [],
|
||||
valid_from=row[34],
|
||||
valid_until=row[35],
|
||||
revision_type=row[36],
|
||||
supersedes=row[37],
|
||||
bundle_id=row[38],
|
||||
)
|
||||
|
||||
@@ -13,19 +13,28 @@ Author: Semantica Contributors
|
||||
License: MIT
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
class ReasoningEngineWithProvenance:
|
||||
"""Reasoning engine with provenance tracking."""
|
||||
|
||||
def __init__(self, provenance: bool = False, **config):
|
||||
def __init__(
|
||||
self,
|
||||
provenance: bool = False,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**config,
|
||||
):
|
||||
from .reasoning_engine import ReasoningEngine
|
||||
|
||||
self.provenance = provenance
|
||||
self._engine = ReasoningEngine(**config)
|
||||
self._prov_manager = None
|
||||
self._agent_id = agent_id or self.__class__.__name__
|
||||
self._is_automated = is_automated
|
||||
|
||||
if provenance:
|
||||
try:
|
||||
@@ -36,13 +45,20 @@ class ReasoningEngineWithProvenance:
|
||||
|
||||
def infer(self, premises: Any, source: str = None, **kwargs):
|
||||
"""Perform inference with provenance tracking."""
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
result = self._engine.infer(premises, **kwargs)
|
||||
activity_ended_at_time = datetime.utcnow().isoformat()
|
||||
|
||||
if self.provenance and self._prov_manager:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=f"inference_{uuid.uuid4().hex[:8]}",
|
||||
source=source or "reasoning_engine",
|
||||
entity_type="inference",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
metadata={
|
||||
"premises_count": len(premises) if hasattr(premises, '__len__') else 1,
|
||||
"confidence": getattr(result, 'confidence', None)
|
||||
|
||||
@@ -37,6 +37,7 @@ License: MIT
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
@@ -48,16 +49,27 @@ class ProvenanceMixin:
|
||||
added to any extraction class without modifying its core functionality.
|
||||
"""
|
||||
|
||||
def __init__(self, provenance: bool = False, **kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
provenance: bool = False,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Initialize provenance tracking.
|
||||
|
||||
Args:
|
||||
provenance: Enable provenance tracking (default: False)
|
||||
agent_id: Agent identifier for accountability (issue #825); defaults
|
||||
to the wrapping class name
|
||||
is_automated: Whether this agent acted without direct human review
|
||||
**kwargs: Additional arguments passed to parent class
|
||||
"""
|
||||
self.provenance = provenance
|
||||
self._prov_manager = None
|
||||
self._agent_id = agent_id or self.__class__.__name__
|
||||
self._is_automated = is_automated
|
||||
|
||||
if provenance:
|
||||
try:
|
||||
@@ -84,10 +96,17 @@ class ProvenanceMixin:
|
||||
**metadata: Additional metadata to track
|
||||
"""
|
||||
if self.provenance and self._prov_manager:
|
||||
activity_started_at_time = metadata.pop("activity_started_at_time", None)
|
||||
activity_ended_at_time = metadata.pop("activity_ended_at_time", None)
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=entity_id,
|
||||
source=source,
|
||||
entity_type=entity_type,
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
@@ -105,7 +124,13 @@ class NERExtractorWithProvenance(ProvenanceMixin):
|
||||
>>> # Each entity is tracked with source, confidence, and metadata
|
||||
"""
|
||||
|
||||
def __init__(self, provenance: bool = False, **config):
|
||||
def __init__(
|
||||
self,
|
||||
provenance: bool = False,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**config,
|
||||
):
|
||||
"""
|
||||
Initialize NER extractor with optional provenance.
|
||||
|
||||
@@ -115,7 +140,9 @@ class NERExtractorWithProvenance(ProvenanceMixin):
|
||||
"""
|
||||
from .ner_extractor import NERExtractor
|
||||
|
||||
ProvenanceMixin.__init__(self, provenance=provenance)
|
||||
ProvenanceMixin.__init__(
|
||||
self, provenance=provenance, agent_id=agent_id, is_automated=is_automated
|
||||
)
|
||||
self._extractor = NERExtractor(**config)
|
||||
|
||||
def extract(self, text: str, source: Optional[str] = None, **kwargs):
|
||||
@@ -130,7 +157,9 @@ class NERExtractorWithProvenance(ProvenanceMixin):
|
||||
Returns:
|
||||
List of extracted entities (same as original NERExtractor)
|
||||
"""
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
entities = self._extractor.extract(text, **kwargs)
|
||||
activity_ended_at_time = datetime.utcnow().isoformat()
|
||||
|
||||
if self.provenance:
|
||||
for entity in entities:
|
||||
@@ -150,7 +179,9 @@ class NERExtractorWithProvenance(ProvenanceMixin):
|
||||
label=entity.label,
|
||||
confidence=getattr(entity, 'confidence', 1.0),
|
||||
start=entity.start,
|
||||
end=entity.end
|
||||
end=entity.end,
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
)
|
||||
|
||||
return entities
|
||||
@@ -167,7 +198,13 @@ class RelationExtractorWithProvenance(ProvenanceMixin):
|
||||
Wraps the original RelationExtractor and tracks all extracted relations.
|
||||
"""
|
||||
|
||||
def __init__(self, provenance: bool = False, **config):
|
||||
def __init__(
|
||||
self,
|
||||
provenance: bool = False,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**config,
|
||||
):
|
||||
"""
|
||||
Initialize relation extractor with optional provenance.
|
||||
|
||||
@@ -177,7 +214,9 @@ class RelationExtractorWithProvenance(ProvenanceMixin):
|
||||
"""
|
||||
from .relation_extractor import RelationExtractor
|
||||
|
||||
ProvenanceMixin.__init__(self, provenance=provenance)
|
||||
ProvenanceMixin.__init__(
|
||||
self, provenance=provenance, agent_id=agent_id, is_automated=is_automated
|
||||
)
|
||||
self._extractor = RelationExtractor(**config)
|
||||
|
||||
def extract(self, text: str, source: Optional[str] = None, **kwargs):
|
||||
@@ -192,7 +231,9 @@ class RelationExtractorWithProvenance(ProvenanceMixin):
|
||||
Returns:
|
||||
List of extracted relations
|
||||
"""
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
relations = self._extractor.extract(text, **kwargs)
|
||||
activity_ended_at_time = datetime.utcnow().isoformat()
|
||||
|
||||
if self.provenance:
|
||||
for relation in relations:
|
||||
@@ -211,7 +252,9 @@ class RelationExtractorWithProvenance(ProvenanceMixin):
|
||||
subject=relation.subject,
|
||||
predicate=relation.predicate,
|
||||
object=relation.object,
|
||||
confidence=getattr(relation, 'confidence', 1.0)
|
||||
confidence=getattr(relation, 'confidence', 1.0),
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
)
|
||||
|
||||
return relations
|
||||
@@ -228,7 +271,13 @@ class EventDetectorWithProvenance(ProvenanceMixin):
|
||||
Wraps the original EventDetector and tracks all detected events.
|
||||
"""
|
||||
|
||||
def __init__(self, provenance: bool = False, **config):
|
||||
def __init__(
|
||||
self,
|
||||
provenance: bool = False,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**config,
|
||||
):
|
||||
"""
|
||||
Initialize event detector with optional provenance.
|
||||
|
||||
@@ -238,7 +287,9 @@ class EventDetectorWithProvenance(ProvenanceMixin):
|
||||
"""
|
||||
from .event_detector import EventDetector
|
||||
|
||||
ProvenanceMixin.__init__(self, provenance=provenance)
|
||||
ProvenanceMixin.__init__(
|
||||
self, provenance=provenance, agent_id=agent_id, is_automated=is_automated
|
||||
)
|
||||
self._detector = EventDetector(**config)
|
||||
|
||||
def detect(self, text: str, source: Optional[str] = None, **kwargs):
|
||||
@@ -253,7 +304,9 @@ class EventDetectorWithProvenance(ProvenanceMixin):
|
||||
Returns:
|
||||
List of detected events
|
||||
"""
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
events = self._detector.detect(text, **kwargs)
|
||||
activity_ended_at_time = datetime.utcnow().isoformat()
|
||||
|
||||
if self.provenance:
|
||||
for event in events:
|
||||
@@ -271,7 +324,9 @@ class EventDetectorWithProvenance(ProvenanceMixin):
|
||||
entity_type="event",
|
||||
event_type=event.type,
|
||||
trigger=event.trigger,
|
||||
confidence=getattr(event, 'confidence', 1.0)
|
||||
confidence=getattr(event, 'confidence', 1.0),
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
)
|
||||
|
||||
return events
|
||||
@@ -288,7 +343,13 @@ class CoreferenceResolverWithProvenance(ProvenanceMixin):
|
||||
Wraps the original CoreferenceResolver and tracks coreference chains.
|
||||
"""
|
||||
|
||||
def __init__(self, provenance: bool = False, **config):
|
||||
def __init__(
|
||||
self,
|
||||
provenance: bool = False,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**config,
|
||||
):
|
||||
"""
|
||||
Initialize coreference resolver with optional provenance.
|
||||
|
||||
@@ -298,7 +359,9 @@ class CoreferenceResolverWithProvenance(ProvenanceMixin):
|
||||
"""
|
||||
from .coreference_resolver import CoreferenceResolver
|
||||
|
||||
ProvenanceMixin.__init__(self, provenance=provenance)
|
||||
ProvenanceMixin.__init__(
|
||||
self, provenance=provenance, agent_id=agent_id, is_automated=is_automated
|
||||
)
|
||||
self._resolver = CoreferenceResolver(**config)
|
||||
|
||||
def resolve(self, text: str, source: Optional[str] = None, **kwargs):
|
||||
@@ -313,7 +376,9 @@ class CoreferenceResolverWithProvenance(ProvenanceMixin):
|
||||
Returns:
|
||||
Coreference chains
|
||||
"""
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
chains = self._resolver.resolve(text, **kwargs)
|
||||
activity_ended_at_time = datetime.utcnow().isoformat()
|
||||
|
||||
if self.provenance:
|
||||
for chain in chains:
|
||||
@@ -329,7 +394,9 @@ class CoreferenceResolverWithProvenance(ProvenanceMixin):
|
||||
entity_id=chain_id,
|
||||
source=source or text[:100],
|
||||
entity_type="coreference_chain",
|
||||
mentions=len(chain.mentions) if hasattr(chain, 'mentions') else 0
|
||||
mentions=len(chain.mentions) if hasattr(chain, 'mentions') else 0,
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
)
|
||||
|
||||
return chains
|
||||
@@ -346,7 +413,13 @@ class TripletExtractorWithProvenance(ProvenanceMixin):
|
||||
Wraps the original TripletExtractor and tracks all extracted triplets.
|
||||
"""
|
||||
|
||||
def __init__(self, provenance: bool = False, **config):
|
||||
def __init__(
|
||||
self,
|
||||
provenance: bool = False,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**config,
|
||||
):
|
||||
"""
|
||||
Initialize triplet extractor with optional provenance.
|
||||
|
||||
@@ -356,7 +429,9 @@ class TripletExtractorWithProvenance(ProvenanceMixin):
|
||||
"""
|
||||
from .triplet_extractor import TripletExtractor
|
||||
|
||||
ProvenanceMixin.__init__(self, provenance=provenance)
|
||||
ProvenanceMixin.__init__(
|
||||
self, provenance=provenance, agent_id=agent_id, is_automated=is_automated
|
||||
)
|
||||
self._extractor = TripletExtractor(**config)
|
||||
|
||||
def extract(self, text: str, source: Optional[str] = None, **kwargs):
|
||||
@@ -371,7 +446,9 @@ class TripletExtractorWithProvenance(ProvenanceMixin):
|
||||
Returns:
|
||||
List of extracted triplets
|
||||
"""
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
triplets = self._extractor.extract(text, **kwargs)
|
||||
activity_ended_at_time = datetime.utcnow().isoformat()
|
||||
|
||||
if self.provenance:
|
||||
for triplet in triplets:
|
||||
@@ -390,7 +467,9 @@ class TripletExtractorWithProvenance(ProvenanceMixin):
|
||||
subject=triplet.subject,
|
||||
predicate=triplet.predicate,
|
||||
object=triplet.object,
|
||||
confidence=getattr(triplet, 'confidence', 1.0)
|
||||
confidence=getattr(triplet, 'confidence', 1.0),
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
)
|
||||
|
||||
return triplets
|
||||
|
||||
@@ -13,19 +13,28 @@ Author: Semantica Contributors
|
||||
License: MIT
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
class TripletStoreWithProvenance:
|
||||
"""Triplet store with provenance tracking."""
|
||||
|
||||
def __init__(self, provenance: bool = False, **config):
|
||||
def __init__(
|
||||
self,
|
||||
provenance: bool = False,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**config,
|
||||
):
|
||||
from .triplet_store import TripletStore
|
||||
|
||||
self.provenance = provenance
|
||||
self._store = TripletStore(**config)
|
||||
self._prov_manager = None
|
||||
self._agent_id = agent_id or self.__class__.__name__
|
||||
self._is_automated = is_automated
|
||||
|
||||
if provenance:
|
||||
try:
|
||||
@@ -36,13 +45,20 @@ class TripletStoreWithProvenance:
|
||||
|
||||
def add_triplet(self, subject: Any, predicate: Any, obj: Any, source: str = None, **kwargs):
|
||||
"""Add triplet with provenance tracking."""
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
result = self._store.add_triplet(subject, predicate, obj, **kwargs)
|
||||
activity_ended_at_time = datetime.utcnow().isoformat()
|
||||
|
||||
if self.provenance and self._prov_manager:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=f"triplet_{uuid.uuid4().hex[:8]}",
|
||||
source=source or "triplet_store",
|
||||
entity_type="triplet",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
metadata={
|
||||
"subject": str(subject),
|
||||
"predicate": str(predicate),
|
||||
|
||||
@@ -13,19 +13,28 @@ Author: Semantica Contributors
|
||||
License: MIT
|
||||
"""
|
||||
|
||||
from typing import List, Any
|
||||
from typing import List, Any, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
class VectorStoreWithProvenance:
|
||||
"""Vector store with provenance tracking."""
|
||||
|
||||
def __init__(self, provenance: bool = False, **config):
|
||||
def __init__(
|
||||
self,
|
||||
provenance: bool = False,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**config,
|
||||
):
|
||||
from .vector_store import VectorStore
|
||||
|
||||
self.provenance = provenance
|
||||
self._store = VectorStore(**config)
|
||||
self._prov_manager = None
|
||||
self._agent_id = agent_id or self.__class__.__name__
|
||||
self._is_automated = is_automated
|
||||
|
||||
if provenance:
|
||||
try:
|
||||
@@ -36,13 +45,20 @@ class VectorStoreWithProvenance:
|
||||
|
||||
def add_vectors(self, vectors: List[Any], source: str = None, **kwargs):
|
||||
"""Add vectors with provenance tracking."""
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
result = self._store.add_vectors(vectors, **kwargs)
|
||||
activity_ended_at_time = datetime.utcnow().isoformat()
|
||||
|
||||
if self.provenance and self._prov_manager:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=f"vectors_{uuid.uuid4().hex[:8]}",
|
||||
source=source or "vector_store",
|
||||
entity_type="vector_collection",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
metadata={
|
||||
"count": len(vectors),
|
||||
"dimensions": len(vectors[0]) if vectors else 0
|
||||
|
||||
@@ -11,19 +11,28 @@ Author: Semantica Contributors
|
||||
License: MIT
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
class VisualizerWithProvenance:
|
||||
"""Visualizer with provenance tracking."""
|
||||
|
||||
def __init__(self, provenance: bool = False, **config):
|
||||
def __init__(
|
||||
self,
|
||||
provenance: bool = False,
|
||||
agent_id: Optional[str] = None,
|
||||
is_automated: bool = True,
|
||||
**config,
|
||||
):
|
||||
from .visualizer import Visualizer
|
||||
|
||||
self.provenance = provenance
|
||||
self._visualizer = Visualizer(**config)
|
||||
self._prov_manager = None
|
||||
self._agent_id = agent_id or self.__class__.__name__
|
||||
self._is_automated = is_automated
|
||||
|
||||
if provenance:
|
||||
try:
|
||||
@@ -34,13 +43,20 @@ class VisualizerWithProvenance:
|
||||
|
||||
def visualize(self, data: Any, output: str = None, **kwargs):
|
||||
"""Visualize data with provenance tracking."""
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
result = self._visualizer.visualize(data, output=output, **kwargs)
|
||||
activity_ended_at_time = datetime.utcnow().isoformat()
|
||||
|
||||
if self.provenance and self._prov_manager:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=f"viz_{uuid.uuid4().hex[:8]}",
|
||||
source="visualization",
|
||||
entity_type="visualization",
|
||||
agent_id=self._agent_id,
|
||||
agent_type="software_agent",
|
||||
is_automated=self._is_automated,
|
||||
activity_started_at_time=activity_started_at_time,
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
metadata={"output": output, "type": kwargs.get('type', 'unknown')}
|
||||
)
|
||||
|
||||
|
||||
@@ -913,5 +913,614 @@ class TestProvenanceManager:
|
||||
assert kwargs.get("exc_info") is True
|
||||
|
||||
|
||||
class TestAgentTyping:
|
||||
"""Issue #825, Part A item 3 — agent_id/agent_type/is_automated actually
|
||||
populate on tracked entries (previously a dead field: no track_* method
|
||||
read agent_id from kwargs at all)."""
|
||||
|
||||
def test_track_entity_scalar_agent_kwargs(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
entry = prov_mgr.track_entity(
|
||||
"e1", source="doc1",
|
||||
agent_id="alice", agent_type="person", is_automated=False, role="approver",
|
||||
)
|
||||
assert entry.agent_id == "alice"
|
||||
assert entry.agent_type == "person"
|
||||
assert entry.is_automated is False
|
||||
assert entry.role == "approver"
|
||||
|
||||
def test_track_entity_agent_record_kwarg(self):
|
||||
from semantica.provenance import AgentRecord
|
||||
prov_mgr = ProvenanceManager()
|
||||
agent = AgentRecord(id="reviewer_bob", agent_type="person", is_automated=False)
|
||||
entry = prov_mgr.track_entity("e1", source="doc1", agent=agent, role="approver")
|
||||
assert entry.agent_id == "reviewer_bob"
|
||||
assert entry.agent_type == "person"
|
||||
assert entry.is_automated is False
|
||||
assert entry.role == "approver"
|
||||
|
||||
def test_track_entity_default_agent_unchanged(self):
|
||||
"""No agent kwargs supplied should still default to 'semantica' (back-compat)."""
|
||||
prov_mgr = ProvenanceManager()
|
||||
entry = prov_mgr.track_entity("e1", source="doc1")
|
||||
assert entry.agent_id == "semantica"
|
||||
assert entry.agent_type == "software_agent"
|
||||
assert entry.is_automated is True
|
||||
|
||||
def test_track_relationship_agent_kwargs(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
entry = prov_mgr.track_relationship("r1", source="doc1", agent_id="bot1")
|
||||
assert entry.agent_id == "bot1"
|
||||
|
||||
def test_track_chunk_agent_kwargs_not_leaked_into_metadata(self):
|
||||
"""agent_id/agent_type/is_automated passed to track_chunk must populate
|
||||
the real fields, not leak into the opaque metadata blob."""
|
||||
prov_mgr = ProvenanceManager()
|
||||
entry = prov_mgr.track_chunk(
|
||||
"c1", source_document="doc1", agent_id="chunker_v2", note="hello"
|
||||
)
|
||||
assert entry.agent_id == "chunker_v2"
|
||||
assert "agent_id" not in entry.metadata
|
||||
assert entry.metadata.get("note") == "hello"
|
||||
|
||||
def test_track_property_source_agent_kwargs(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
source = SourceReference(document="doc1")
|
||||
entry = prov_mgr.track_property_source(
|
||||
"e1", "prop", "value", source, agent_id="prop_tracker"
|
||||
)
|
||||
assert entry.agent_id == "prop_tracker"
|
||||
assert "agent_id" not in entry.metadata
|
||||
|
||||
def test_track_entities_batch_agent_id_not_swallowed_into_metadata(self):
|
||||
"""Regression test for the documented bug: track_entities_batch used to
|
||||
merge agent_id/entity_type/activity_id into the metadata dict instead
|
||||
of forwarding them as real track_entity kwargs."""
|
||||
prov_mgr = ProvenanceManager()
|
||||
count = prov_mgr.track_entities_batch(
|
||||
[{"id": "b1"}, {"id": "b2"}],
|
||||
source="doc_batch",
|
||||
agent_id="batch_service_v2",
|
||||
entity_type="credit_feature",
|
||||
activity_id="bureau_parsing",
|
||||
extra_note="kept as free-form metadata",
|
||||
)
|
||||
assert count == 2
|
||||
for eid in ("b1", "b2"):
|
||||
entry = prov_mgr.storage.retrieve(eid)
|
||||
assert entry.agent_id == "batch_service_v2"
|
||||
assert entry.entity_type == "credit_feature"
|
||||
assert entry.activity_id == "bureau_parsing"
|
||||
assert "agent_id" not in entry.metadata
|
||||
assert entry.metadata.get("extra_note") == "kept as free-form metadata"
|
||||
|
||||
|
||||
class TestVersioningVsDerivation:
|
||||
"""Issue #825, Part A item 4 — previous_version_id (correction) is
|
||||
additive alongside derived_from_id (cross-source derivation); both are
|
||||
populated without disturbing the legacy parent_entity_id field."""
|
||||
|
||||
def test_retrack_without_parent_sets_previous_version_id_only(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
prov_mgr.track_entity("X", source="doc_1")
|
||||
v2 = prov_mgr.track_entity("X", source="doc_1")
|
||||
|
||||
assert v2.previous_version_id is not None
|
||||
assert v2.previous_version_id.startswith("X:v:")
|
||||
assert v2.derived_from_id is None
|
||||
# Legacy field behavior is unchanged
|
||||
assert v2.parent_entity_id == v2.previous_version_id
|
||||
|
||||
def test_retrack_with_explicit_parent_sets_both_fields(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
prov_mgr.track_entity("X", source="doc_1")
|
||||
v2 = prov_mgr.track_entity("X", source="doc_1", parent_entity_id="explicit_parent")
|
||||
|
||||
assert v2.derived_from_id == "explicit_parent"
|
||||
assert v2.previous_version_id is not None
|
||||
assert v2.previous_version_id.startswith("X:v:")
|
||||
# Legacy field keeps explicit-wins semantics
|
||||
assert v2.parent_entity_id == "explicit_parent"
|
||||
|
||||
def test_track_chunk_split_sets_derived_from_id(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
entry = prov_mgr.track_chunk(
|
||||
"c2", source_document="doc1", parent_chunk_id="c1"
|
||||
)
|
||||
assert entry.derived_from_id == "c1"
|
||||
assert entry.previous_version_id is None
|
||||
assert entry.parent_entity_id == "c1"
|
||||
|
||||
|
||||
class TestInvalidation:
|
||||
"""Issue #825, Part A item 1 — tombstone instead of hard delete."""
|
||||
|
||||
def test_invalidate_marks_entry_without_deleting(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
prov_mgr.track_entity("e1", source="doc1")
|
||||
|
||||
result = prov_mgr.invalidate("e1", agent_id="reviewer_jane", reason="retracted")
|
||||
|
||||
assert result.invalidated is True
|
||||
assert result.invalidated_by == "reviewer_jane"
|
||||
assert result.invalidation_reason == "retracted"
|
||||
assert result.invalidated_at_time is not None
|
||||
|
||||
# Entry remains visible via retrieve (tombstone, not delete)
|
||||
stored = prov_mgr.storage.retrieve("e1")
|
||||
assert stored is not None
|
||||
assert stored.invalidated is True
|
||||
|
||||
def test_invalidate_unknown_entity_raises(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
with pytest.raises(ValueError):
|
||||
prov_mgr.invalidate("never_tracked", agent_id="reviewer_jane")
|
||||
|
||||
def test_check_reports_invalidated_count(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
prov_mgr.track_entity("e1", source="doc1")
|
||||
prov_mgr.track_entity("e2", source="doc1")
|
||||
prov_mgr.invalidate("e1", agent_id="reviewer_jane")
|
||||
|
||||
result = prov_mgr.check()
|
||||
assert result["invalidated_count"] == 1
|
||||
|
||||
|
||||
class TestHashChain:
|
||||
"""Issue #825, Part A item 2 — hash-chained integrity: chained checksums
|
||||
detect wholesale row deletion, which per-row checksums alone cannot."""
|
||||
|
||||
def test_verify_chain_valid_on_clean_history(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
prov_mgr.track_entity("e1", source="doc1")
|
||||
prov_mgr.track_entity("e2", source="doc1")
|
||||
prov_mgr.track_entity("X", source="doc1")
|
||||
prov_mgr.track_entity("X", source="doc1") # triggers archival
|
||||
prov_mgr.track_entity("X", source="doc1", parent_entity_id="explicit_p")
|
||||
|
||||
result = prov_mgr.verify_chain()
|
||||
assert result["valid"] is True
|
||||
assert result["broken_links"] == []
|
||||
assert result["total_entries"] == 5
|
||||
|
||||
def test_verify_chain_detects_deleted_row(self, tmp_path):
|
||||
"""A hard delete of a row must break the chain for whatever followed it."""
|
||||
import sqlite3
|
||||
db_path = str(tmp_path / "chain.db")
|
||||
prov_mgr = ProvenanceManager(storage_path=db_path)
|
||||
prov_mgr.track_entity("e1", source="doc1")
|
||||
prov_mgr.track_entity("e2", source="doc1")
|
||||
prov_mgr.track_entity("e3", source="doc1")
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("DELETE FROM provenance WHERE entity_id = 'e2'")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
prov_mgr2 = ProvenanceManager(storage_path=db_path)
|
||||
result = prov_mgr2.verify_chain()
|
||||
assert result["valid"] is False
|
||||
assert any(link["reason"] == "chain_break" for link in result["broken_links"])
|
||||
|
||||
def test_sequence_ids_are_assigned_and_monotonic(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
e1 = prov_mgr.track_entity("e1", source="doc1")
|
||||
e2 = prov_mgr.track_entity("e2", source="doc1")
|
||||
assert e1.sequence_id is not None
|
||||
assert e2.sequence_id is not None
|
||||
assert e2.sequence_id > e1.sequence_id
|
||||
assert e2.previous_checksum == e1.checksum
|
||||
|
||||
def test_chain_survives_interleaved_retrack_and_invalidate(self, tmp_path):
|
||||
"""Regression test: an entry that already chained its previous_checksum
|
||||
from another entity's checksum (Y -> X) must stay valid even after X
|
||||
is later retracked (archived/relabeled) and then invalidated — both
|
||||
of which write NEW entries under X's canonical key. Mutating X's
|
||||
existing row in place (rather than archiving-then-appending) used to
|
||||
silently orphan Y's chain link, producing a false-positive break."""
|
||||
db_path = str(tmp_path / "interleaved.db")
|
||||
prov_mgr = ProvenanceManager(storage_path=db_path)
|
||||
|
||||
prov_mgr.track_entity("e1", source="doc1")
|
||||
prov_mgr.track_entity("X", source="doc1")
|
||||
prov_mgr.track_entity("Y", source="doc1", parent_entity_id="X")
|
||||
prov_mgr.track_entity("X", source="doc1") # retrack: archives old X
|
||||
prov_mgr.track_entity("Z", source="doc1", parent_entity_id="X")
|
||||
prov_mgr.invalidate("X", agent_id="reviewer") # archives again
|
||||
prov_mgr.track_entity("W", source="doc1", parent_entity_id="X")
|
||||
|
||||
result = prov_mgr.verify_chain()
|
||||
assert result["valid"] is True
|
||||
assert result["broken_links"] == []
|
||||
|
||||
def test_invalidate_does_not_break_chain(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
prov_mgr.track_entity("e1", source="doc1")
|
||||
prov_mgr.track_entity("e2", source="doc1", parent_entity_id="e1")
|
||||
prov_mgr.invalidate("e1", agent_id="reviewer_jane")
|
||||
|
||||
result = prov_mgr.verify_chain()
|
||||
assert result["valid"] is True
|
||||
|
||||
|
||||
class TestDownstreamLineage:
|
||||
"""Issue #825, Part A item 5 — downstream/descendant traversal (reverse
|
||||
BFS), complementing the existing upstream-only trace_lineage/get_lineage."""
|
||||
|
||||
def test_get_descendants_finds_direct_child(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
prov_mgr.track_entity("parent1", source="doc1")
|
||||
prov_mgr.track_entity(
|
||||
"child1", source="doc1", parent_entity_id="parent1", used_entities=["parent1"]
|
||||
)
|
||||
|
||||
result = prov_mgr.get_descendants("parent1")
|
||||
entity_ids = {e["entity_id"] for e in result["entries"]}
|
||||
assert "child1" in entity_ids
|
||||
|
||||
def test_get_descendants_transitive(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
prov_mgr.track_entity("a", source="doc1")
|
||||
prov_mgr.track_entity("b", source="doc1", parent_entity_id="a")
|
||||
prov_mgr.track_entity("c", source="doc1", parent_entity_id="b")
|
||||
|
||||
result = prov_mgr.get_descendants("a")
|
||||
entity_ids = {e["entity_id"] for e in result["entries"]}
|
||||
assert entity_ids == {"b", "c"}
|
||||
|
||||
def test_get_descendants_empty_for_leaf(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
prov_mgr.track_entity("leaf", source="doc1")
|
||||
assert prov_mgr.get_descendants("leaf") == {}
|
||||
|
||||
def test_descendants_work_on_sqlite_backend(self, tmp_path):
|
||||
db_path = str(tmp_path / "desc.db")
|
||||
prov_mgr = ProvenanceManager(storage_path=db_path)
|
||||
prov_mgr.track_entity("parent1", source="doc1")
|
||||
prov_mgr.track_entity("child1", source="doc1", parent_entity_id="parent1")
|
||||
|
||||
result = prov_mgr.get_descendants("parent1")
|
||||
entity_ids = {e["entity_id"] for e in result["entries"]}
|
||||
assert "child1" in entity_ids
|
||||
|
||||
|
||||
class TestQualifiedExport:
|
||||
"""Issue #825, Part A item 6 — qualified Association with hadRole, plus
|
||||
qualified Invalidation, in the RDF export."""
|
||||
|
||||
def test_export_prov_includes_qualified_association_and_role(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
prov_mgr.track_entity(
|
||||
"e1", source="doc1", agent_id="alice", agent_type="person", role="approver"
|
||||
)
|
||||
ttl = prov_mgr.export_prov(format="turtle")
|
||||
assert "qualifiedAssociation" in ttl
|
||||
assert "hadRole" in ttl
|
||||
assert "role_approver" in ttl
|
||||
assert "Person" in ttl
|
||||
|
||||
def test_export_prov_includes_qualified_invalidation(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
prov_mgr.track_entity("e1", source="doc1")
|
||||
prov_mgr.invalidate("e1", agent_id="reviewer_jane", reason="retracted")
|
||||
|
||||
ttl = prov_mgr.export_prov(format="turtle")
|
||||
assert "qualifiedInvalidation" in ttl
|
||||
assert "Invalidation" in ttl
|
||||
|
||||
|
||||
class TestTypedActivity:
|
||||
"""Issue #825, Part B Tier 1 — typed Activity via ActivityRecord."""
|
||||
|
||||
def test_track_entity_activity_record_kwarg(self):
|
||||
from semantica.provenance import ActivityRecord
|
||||
prov_mgr = ProvenanceManager()
|
||||
activity = ActivityRecord(
|
||||
id="bureau_parsing_run_42",
|
||||
started_at_time="2026-01-01T00:00:00",
|
||||
ended_at_time="2026-01-01T00:00:03",
|
||||
)
|
||||
entry = prov_mgr.track_entity("e1", source="doc1", activity=activity)
|
||||
assert entry.activity_id == "bureau_parsing_run_42"
|
||||
assert entry.activity_started_at_time == "2026-01-01T00:00:00"
|
||||
assert entry.activity_ended_at_time == "2026-01-01T00:00:03"
|
||||
|
||||
def test_track_entity_activity_scalar_kwargs(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
entry = prov_mgr.track_entity(
|
||||
"e1", source="doc1",
|
||||
activity_id="parse_step", activity_started_at_time="t0", activity_ended_at_time="t1",
|
||||
)
|
||||
assert entry.activity_id == "parse_step"
|
||||
assert entry.activity_started_at_time == "t0"
|
||||
assert entry.activity_ended_at_time == "t1"
|
||||
|
||||
def test_export_prov_qualified_generation_usage_derivation(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
prov_mgr.track_entity("parent1", source="doc1")
|
||||
prov_mgr.track_entity(
|
||||
"child1", source="doc1",
|
||||
parent_entity_id="parent1", used_entities=["parent1"],
|
||||
activity_id="transform",
|
||||
)
|
||||
ttl = prov_mgr.export_prov(format="turtle")
|
||||
assert "qualifiedGeneration" in ttl
|
||||
assert "Generation" in ttl
|
||||
assert "qualifiedUsage" in ttl
|
||||
assert "Usage" in ttl
|
||||
assert "qualifiedDerivation" in ttl
|
||||
assert "Derivation" in ttl
|
||||
|
||||
def test_export_prov_activity_timing(self):
|
||||
from semantica.provenance import ActivityRecord
|
||||
prov_mgr = ProvenanceManager()
|
||||
prov_mgr.track_entity(
|
||||
"e1", source="doc1",
|
||||
activity=ActivityRecord(id="act1", started_at_time="2026-01-01T00:00:00",
|
||||
ended_at_time="2026-01-01T00:00:05"),
|
||||
)
|
||||
ttl = prov_mgr.export_prov(format="turtle")
|
||||
assert "startedAtTime" in ttl
|
||||
assert "endedAtTime" in ttl
|
||||
|
||||
|
||||
class TestAssociationDelegationChaining:
|
||||
"""Issue #825, Part B Tier 2 — wasAssociatedWith, actedOnBehalfOf, wasInformedBy."""
|
||||
|
||||
def test_acted_on_behalf_of(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
entry = prov_mgr.track_entity(
|
||||
"e1", source="doc1", agent_id="bot1", acted_on_behalf_of="org1"
|
||||
)
|
||||
assert entry.acted_on_behalf_of == "org1"
|
||||
ttl = prov_mgr.export_prov(format="turtle")
|
||||
assert "actedOnBehalfOf" in ttl
|
||||
|
||||
def test_informed_by_activities(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
entry = prov_mgr.track_entity(
|
||||
"e1", source="doc1", activity_id="parse", informed_by=["ingest_activity"]
|
||||
)
|
||||
assert entry.informed_by_activities == ["ingest_activity"]
|
||||
ttl = prov_mgr.export_prov(format="turtle")
|
||||
assert "wasInformedBy" in ttl
|
||||
|
||||
def test_was_associated_with_in_export(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
prov_mgr.track_entity("e1", source="doc1", agent_id="alice", activity_id="act1")
|
||||
ttl = prov_mgr.export_prov(format="turtle")
|
||||
assert "wasAssociatedWith" in ttl
|
||||
|
||||
def test_track_entities_batch_forwards_tier2_kwargs(self):
|
||||
"""Regression test for the batch-kwargs-vs-metadata bug (issue #825,
|
||||
Part A) extended to the new Tier 2/3 keys."""
|
||||
prov_mgr = ProvenanceManager()
|
||||
count = prov_mgr.track_entities_batch(
|
||||
[{"id": "b1"}],
|
||||
source="doc_batch",
|
||||
activity_id="bureau_parsing",
|
||||
acted_on_behalf_of="org1",
|
||||
informed_by=["ingest_activity"],
|
||||
bundle_id="run_1",
|
||||
)
|
||||
assert count == 1
|
||||
entry = prov_mgr.storage.retrieve("b1")
|
||||
assert entry.activity_id == "bureau_parsing"
|
||||
assert entry.acted_on_behalf_of == "org1"
|
||||
assert entry.informed_by_activities == ["ingest_activity"]
|
||||
assert entry.bundle_id == "run_1"
|
||||
assert "acted_on_behalf_of" not in entry.metadata
|
||||
|
||||
|
||||
class TestBitemporalMerge:
|
||||
"""Issue #825, Part B Tier 3 — revision_history()/query_recorded_between()
|
||||
close kg.ProvenanceTracker's documented 'no direct equivalent yet' gaps."""
|
||||
|
||||
def test_revision_history_ascending_with_valid_until(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
prov_mgr.track_entity("X", source="doc1", agent_id="alice")
|
||||
prov_mgr.track_entity("X", source="doc1", agent_id="bob", revision_type="correction")
|
||||
prov_mgr.track_entity("X", source="doc1", agent_id="carol", supersedes="X_old_claim")
|
||||
|
||||
history = prov_mgr.revision_history("X")
|
||||
assert len(history) == 3
|
||||
assert [h["version"] for h in history] == [1, 2, 3]
|
||||
assert history[0]["author"] == "alice"
|
||||
assert history[1]["author"] == "bob"
|
||||
assert history[1]["revision_type"] == "correction"
|
||||
assert history[2]["author"] == "carol"
|
||||
assert history[2]["supersedes"] == "X_old_claim"
|
||||
# Every version except the last has a valid_until set to the next version's timestamp
|
||||
assert history[0]["valid_until"] == history[1]["valid_from"]
|
||||
assert history[1]["valid_until"] == history[2]["valid_from"]
|
||||
assert history[2]["valid_until"] is None
|
||||
|
||||
def test_revision_history_empty_for_untracked_entity(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
assert prov_mgr.revision_history("never_tracked") == []
|
||||
|
||||
def test_revision_history_single_version(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
prov_mgr.track_entity("Y", source="doc1")
|
||||
history = prov_mgr.revision_history("Y")
|
||||
assert len(history) == 1
|
||||
assert history[0]["version"] == 1
|
||||
assert history[0]["valid_until"] is None
|
||||
|
||||
def test_query_recorded_between(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
prov_mgr.track_entity("e1", source="doc1")
|
||||
prov_mgr.track_entity("e2", source="doc1")
|
||||
|
||||
results = prov_mgr.query_recorded_between("2000-01-01T00:00:00", "2100-01-01T00:00:00")
|
||||
entity_ids = {r["entity_id"] for r in results}
|
||||
assert {"e1", "e2"}.issubset(entity_ids)
|
||||
|
||||
no_results = prov_mgr.query_recorded_between("1990-01-01T00:00:00", "1990-01-02T00:00:00")
|
||||
assert no_results == []
|
||||
|
||||
def test_check_flags_missing_informed_by_activity(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
prov_mgr.track_entity("e1", source="doc1", informed_by=["never_tracked_activity"])
|
||||
result = prov_mgr.check()
|
||||
assert result["valid"] is False
|
||||
assert any("never_tracked_activity" in ref for ref in result["missing_references"])
|
||||
|
||||
|
||||
class TestBundleAndBaseUri:
|
||||
"""Issue #825, Part B Tier 3 — prov:Bundle membership and configurable base_uri."""
|
||||
|
||||
def test_bundle_id_produces_bundle_triples(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
prov_mgr.track_entity("e1", source="doc1", bundle_id="ingestion_run_1")
|
||||
ttl = prov_mgr.export_prov(format="turtle")
|
||||
assert "Bundle" in ttl
|
||||
assert "hadMember" in ttl
|
||||
|
||||
def test_default_base_uri_matches_rdf_exporter_namespace(self):
|
||||
from semantica.provenance.manager import DEFAULT_BASE_URI
|
||||
from semantica.export.rdf_exporter import NamespaceManager
|
||||
assert NamespaceManager().namespaces["semantica"] == DEFAULT_BASE_URI
|
||||
|
||||
def test_export_prov_base_uri_override(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
prov_mgr.track_entity("e1", source="doc1")
|
||||
|
||||
default_ttl = prov_mgr.export_prov(format="turtle")
|
||||
assert "https://semantica.dev/ns#" in default_ttl
|
||||
|
||||
custom_ttl = prov_mgr.export_prov(format="turtle", base_uri="https://example.org/kg#")
|
||||
assert "https://example.org/kg#" in custom_ttl
|
||||
assert "https://semantica.dev/ns#" not in custom_ttl
|
||||
|
||||
def test_owl_exporter_default_ontology_uri_matches_shared_namespace(self):
|
||||
"""Issue #825 follow-up — OWLExporter was the one exporter left out of
|
||||
the Part B Tier 3 namespace interlinking; its default ontology_uri
|
||||
must match the same shared DEFAULT_BASE_URI as RDFExporter and
|
||||
export_prov()."""
|
||||
from semantica.provenance.manager import DEFAULT_BASE_URI
|
||||
from semantica.export.owl_exporter import OWLExporter
|
||||
assert OWLExporter().ontology_uri == DEFAULT_BASE_URI
|
||||
|
||||
|
||||
class TestExplicitBitemporalFields:
|
||||
"""Issue #825 follow-up — valid_from/valid_until as explicit,
|
||||
caller-supplied ProvenanceEntry fields (matching the deprecated
|
||||
kg.ProvenanceTracker's actual contract: these were always caller-supplied
|
||||
metadata keys, never auto-computed)."""
|
||||
|
||||
def test_valid_from_valid_until_are_plain_passthrough_fields(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
entry = prov_mgr.track_entity(
|
||||
"price1", source="doc1", valid_from="2026-01-01", valid_until="2026-06-01"
|
||||
)
|
||||
assert entry.valid_from == "2026-01-01"
|
||||
assert entry.valid_until == "2026-06-01"
|
||||
|
||||
def test_revision_history_prefers_explicit_valid_from_until(self):
|
||||
prov_mgr = ProvenanceManager()
|
||||
prov_mgr.track_entity(
|
||||
"price1", source="doc1", valid_from="2026-01-01", valid_until="2026-06-01"
|
||||
)
|
||||
history = prov_mgr.revision_history("price1")
|
||||
assert history[0]["valid_from"] == "2026-01-01"
|
||||
assert history[0]["valid_until"] == "2026-06-01"
|
||||
|
||||
def test_revision_history_falls_back_to_timestamp_when_unset(self):
|
||||
"""Backward-compat: entries that don't set valid_from/valid_until
|
||||
explicitly still get the dynamic timestamp-based derivation."""
|
||||
prov_mgr = ProvenanceManager()
|
||||
prov_mgr.track_entity("X", source="doc1")
|
||||
prov_mgr.track_entity("X", source="doc1")
|
||||
history = prov_mgr.revision_history("X")
|
||||
assert history[0]["valid_until"] == history[1]["valid_from"]
|
||||
assert history[1]["valid_until"] is None
|
||||
|
||||
def test_valid_from_until_survive_sqlite_round_trip(self, tmp_path):
|
||||
db_path = str(tmp_path / "bitemporal.db")
|
||||
prov_mgr = ProvenanceManager(storage_path=db_path)
|
||||
prov_mgr.track_entity(
|
||||
"price1", source="doc1", valid_from="2026-01-01", valid_until="2026-06-01"
|
||||
)
|
||||
prov_mgr2 = ProvenanceManager(storage_path=db_path)
|
||||
entry = prov_mgr2.storage.retrieve("price1")
|
||||
assert entry.valid_from == "2026-01-01"
|
||||
assert entry.valid_until == "2026-06-01"
|
||||
|
||||
|
||||
class TestActivityTimingAcrossWrappers:
|
||||
"""Issue #825 follow-up — activity_started_at_time/ended_at_time wired
|
||||
into all *_provenance.py wrapper modules that measure real work, not
|
||||
just the 2 wrappers from the original Part B pass."""
|
||||
|
||||
def test_embedding_wrapper_records_activity_timing(self):
|
||||
from semantica.embeddings.embeddings_provenance import EmbeddingGeneratorWithProvenance
|
||||
wrapper = EmbeddingGeneratorWithProvenance(provenance=True, agent_id="embed_svc")
|
||||
wrapper._generator.embed = lambda texts, **kw: [[0.1, 0.2] for _ in texts]
|
||||
wrapper.embed(["hello", "world"])
|
||||
entries = wrapper._prov_manager.storage.retrieve_all()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].activity_started_at_time is not None
|
||||
assert entries[0].activity_ended_at_time is not None
|
||||
|
||||
def test_kg_algorithm_tracker_accepts_caller_supplied_activity_timing(self):
|
||||
from semantica.kg.kg_provenance import AlgorithmTrackerWithProvenance
|
||||
tracker = AlgorithmTrackerWithProvenance(provenance=True, agent_id="algo_svc")
|
||||
eid = tracker.track_embedding_computation(
|
||||
graph=object(), algorithm="node2vec", embeddings={"n1": [0.1, 0.2]},
|
||||
parameters={"d": 2},
|
||||
activity_started_at_time="t0", activity_ended_at_time="t1",
|
||||
)
|
||||
entry = tracker._prov_manager.get_provenance(eid)
|
||||
assert entry["activity_started_at_time"] == "t0"
|
||||
assert entry["activity_ended_at_time"] == "t1"
|
||||
|
||||
def test_graph_builder_build_operation_has_no_end_time_yet(self):
|
||||
"""The build-operation marker is recorded before the build runs, so
|
||||
it legitimately has a start but no end time."""
|
||||
from semantica.kg.kg_provenance import GraphBuilderWithProvenance
|
||||
builder = GraphBuilderWithProvenance(provenance=True, agent_id="builder_svc")
|
||||
builder._builder.build_single_source = lambda kg_data, **kw: {"entities": [], "relationships": []}
|
||||
builder.build_single_source({"foo": "bar"})
|
||||
entries = builder._prov_manager.storage.retrieve_all()
|
||||
# NOTE: entity_type isn't asserted here — kg_provenance.py has a
|
||||
# pre-existing, out-of-scope bug where entity_type is nested inside
|
||||
# the metadata dict instead of passed as a track_entity kwarg, so it
|
||||
# never actually populates the real field for this call site.
|
||||
build_entries = [e for e in entries if e.entity_id.startswith("graph_build_single_")]
|
||||
assert len(build_entries) == 1
|
||||
assert build_entries[0].activity_started_at_time is not None
|
||||
assert build_entries[0].activity_ended_at_time is None
|
||||
|
||||
def test_semantic_extract_wrapper_records_activity_timing(self):
|
||||
from semantica.semantic_extract.semantic_extract_provenance import ProvenanceMixin
|
||||
|
||||
class FakeExtractor(ProvenanceMixin):
|
||||
pass
|
||||
|
||||
wrapper = FakeExtractor(provenance=True, agent_id="extract_svc")
|
||||
wrapper._track_extraction(
|
||||
entity_id="e1", source="doc1", entity_type="named_entity",
|
||||
activity_started_at_time="t0", activity_ended_at_time="t1",
|
||||
)
|
||||
entry = wrapper._prov_manager.get_provenance("e1")
|
||||
assert entry["activity_started_at_time"] == "t0"
|
||||
assert entry["activity_ended_at_time"] == "t1"
|
||||
assert "activity_started_at_time" not in entry["metadata"]
|
||||
|
||||
def test_conflicts_wrapper_records_activity_timing(self):
|
||||
from semantica.conflicts.conflicts_provenance import SourceTrackerWithUnifiedBackend
|
||||
|
||||
class FakeSource:
|
||||
document = "doc1"
|
||||
page = 1
|
||||
section = None
|
||||
confidence = 0.9
|
||||
|
||||
tracker = SourceTrackerWithUnifiedBackend(agent_id="conflicts_svc")
|
||||
tracker.track_property_source("e1", "prop", "val", FakeSource())
|
||||
entry = tracker._unified_manager.get_provenance("e1_prop")
|
||||
assert entry["activity_started_at_time"] is not None
|
||||
assert entry["activity_ended_at_time"] is not None
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,14 @@ and PropertySource dataclasses.
|
||||
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
from semantica.provenance.schemas import ProvenanceEntry, SourceReference, PropertySource
|
||||
from semantica.provenance.schemas import (
|
||||
ProvenanceEntry,
|
||||
SourceReference,
|
||||
PropertySource,
|
||||
AgentRecord,
|
||||
ActivityRecord,
|
||||
Invalidation,
|
||||
)
|
||||
|
||||
|
||||
class TestProvenanceEntry:
|
||||
@@ -162,3 +169,116 @@ class TestPropertySource:
|
||||
assert isinstance(data, dict)
|
||||
assert data["property_name"] == "name"
|
||||
assert len(data["sources"]) == 1
|
||||
|
||||
|
||||
class TestProvenanceEntryPart825Fields:
|
||||
"""Issue #825, Part A — new additive fields on ProvenanceEntry."""
|
||||
|
||||
def test_defaults(self):
|
||||
entry = ProvenanceEntry(entity_id="e1", entity_type="entity", activity_id="act")
|
||||
assert entry.agent_type == "software_agent"
|
||||
assert entry.is_automated is True
|
||||
assert entry.role is None
|
||||
assert entry.previous_version_id is None
|
||||
assert entry.derived_from_id is None
|
||||
assert entry.sequence_id is None
|
||||
assert entry.previous_checksum is None
|
||||
assert entry.invalidated is False
|
||||
assert entry.invalidated_at_time is None
|
||||
assert entry.invalidated_by is None
|
||||
assert entry.invalidation_reason is None
|
||||
# Part B additive fields
|
||||
assert entry.activity_started_at_time is None
|
||||
assert entry.activity_ended_at_time is None
|
||||
assert entry.acted_on_behalf_of is None
|
||||
assert entry.informed_by_activities == []
|
||||
assert entry.valid_from is None
|
||||
assert entry.valid_until is None
|
||||
assert entry.revision_type is None
|
||||
assert entry.supersedes is None
|
||||
assert entry.bundle_id is None
|
||||
|
||||
def test_round_trip_to_dict_from_dict(self):
|
||||
entry = ProvenanceEntry(
|
||||
entity_id="e1",
|
||||
entity_type="entity",
|
||||
activity_id="act",
|
||||
agent_id="alice",
|
||||
agent_type="person",
|
||||
is_automated=False,
|
||||
role="approver",
|
||||
previous_version_id="e1:v:1",
|
||||
derived_from_id="source_entity",
|
||||
sequence_id=5,
|
||||
previous_checksum="abc123",
|
||||
invalidated=True,
|
||||
invalidated_at_time="2026-01-01T00:00:00",
|
||||
invalidated_by="reviewer_jane",
|
||||
invalidation_reason="retracted",
|
||||
activity_started_at_time="2026-01-01T00:00:00",
|
||||
activity_ended_at_time="2026-01-01T00:00:05",
|
||||
acted_on_behalf_of="org1",
|
||||
informed_by_activities=["ingest_activity"],
|
||||
valid_from="2026-01-01",
|
||||
valid_until="2026-06-01",
|
||||
revision_type="correction",
|
||||
supersedes="old_claim",
|
||||
bundle_id="ingestion_run_1",
|
||||
)
|
||||
|
||||
data = entry.to_dict()
|
||||
restored = ProvenanceEntry.from_dict(data)
|
||||
|
||||
assert restored == entry
|
||||
|
||||
|
||||
class TestActivityRecord:
|
||||
"""Issue #825, Part B Tier 1 — minimum-viable typed activity."""
|
||||
|
||||
def test_defaults(self):
|
||||
activity = ActivityRecord(id="act1")
|
||||
assert activity.activity_type == "process"
|
||||
assert activity.started_at_time is None
|
||||
assert activity.ended_at_time is None
|
||||
|
||||
def test_round_trip(self):
|
||||
activity = ActivityRecord(
|
||||
id="bureau_parsing_run_42",
|
||||
activity_type="extraction",
|
||||
started_at_time="2026-01-01T00:00:00",
|
||||
ended_at_time="2026-01-01T00:00:03",
|
||||
)
|
||||
data = activity.to_dict()
|
||||
restored = ActivityRecord.from_dict(data)
|
||||
assert restored == activity
|
||||
|
||||
|
||||
class TestAgentRecord:
|
||||
"""Issue #825, Part A item 3 — minimum-viable typed agent."""
|
||||
|
||||
def test_defaults(self):
|
||||
agent = AgentRecord(id="bot1")
|
||||
assert agent.agent_type == "software_agent"
|
||||
assert agent.is_automated is True
|
||||
assert agent.name is None
|
||||
|
||||
def test_round_trip(self):
|
||||
agent = AgentRecord(id="reviewer_jane", agent_type="person", is_automated=False, name="Jane")
|
||||
data = agent.to_dict()
|
||||
restored = AgentRecord.from_dict(data)
|
||||
assert restored == agent
|
||||
|
||||
|
||||
class TestInvalidation:
|
||||
"""Issue #825, Part A item 1 — tombstone record."""
|
||||
|
||||
def test_round_trip(self):
|
||||
inv = Invalidation(
|
||||
entity_id="e1",
|
||||
invalidated_at_time="2026-01-01T00:00:00",
|
||||
invalidated_by="reviewer_jane",
|
||||
reason="retracted",
|
||||
)
|
||||
data = inv.to_dict()
|
||||
restored = Invalidation.from_dict(data)
|
||||
assert restored == inv
|
||||
|
||||
@@ -134,10 +134,116 @@ class TestInMemoryStorage:
|
||||
assert count == 1
|
||||
assert len(storage.retrieve_all()) == 0
|
||||
|
||||
def test_get_chain_head(self):
|
||||
"""Issue #825, Part A item 2 — chain head reporting."""
|
||||
storage = InMemoryStorage()
|
||||
assert storage.get_chain_head() is None
|
||||
|
||||
entry1 = ProvenanceEntry(
|
||||
entity_id="e1", entity_type="entity", activity_id="act",
|
||||
sequence_id=1, checksum="checksum_1",
|
||||
)
|
||||
storage.store(entry1)
|
||||
assert storage.get_chain_head() == (1, "checksum_1")
|
||||
|
||||
entry2 = ProvenanceEntry(
|
||||
entity_id="e2", entity_type="entity", activity_id="act",
|
||||
sequence_id=2, checksum="checksum_2",
|
||||
)
|
||||
storage.store(entry2)
|
||||
assert storage.get_chain_head() == (2, "checksum_2")
|
||||
|
||||
def test_trace_descendants(self):
|
||||
"""Issue #825, Part A item 5 — reverse (downstream) lineage traversal."""
|
||||
storage = InMemoryStorage()
|
||||
storage.store(ProvenanceEntry(entity_id="a", entity_type="entity", activity_id="act"))
|
||||
storage.store(ProvenanceEntry(
|
||||
entity_id="b", entity_type="entity", activity_id="act", parent_entity_id="a"
|
||||
))
|
||||
storage.store(ProvenanceEntry(
|
||||
entity_id="c", entity_type="entity", activity_id="act", parent_entity_id="b"
|
||||
))
|
||||
storage.store(ProvenanceEntry(
|
||||
entity_id="d", entity_type="entity", activity_id="act", used_entities=["a"]
|
||||
))
|
||||
|
||||
descendants = storage.trace_descendants("a")
|
||||
entity_ids = {e.entity_id for e in descendants}
|
||||
assert entity_ids == {"b", "c", "d"}
|
||||
|
||||
def test_trace_descendants_respects_max_depth(self):
|
||||
storage = InMemoryStorage()
|
||||
storage.store(ProvenanceEntry(entity_id="a", entity_type="entity", activity_id="act"))
|
||||
storage.store(ProvenanceEntry(
|
||||
entity_id="b", entity_type="entity", activity_id="act", parent_entity_id="a"
|
||||
))
|
||||
storage.store(ProvenanceEntry(
|
||||
entity_id="c", entity_type="entity", activity_id="act", parent_entity_id="b"
|
||||
))
|
||||
|
||||
descendants = storage.trace_descendants("a", max_depth=1)
|
||||
entity_ids = {e.entity_id for e in descendants}
|
||||
assert entity_ids == {"b"}
|
||||
|
||||
def test_trace_descendants_empty_for_leaf(self):
|
||||
storage = InMemoryStorage()
|
||||
storage.store(ProvenanceEntry(entity_id="leaf", entity_type="entity", activity_id="act"))
|
||||
assert storage.trace_descendants("leaf") == []
|
||||
|
||||
|
||||
class TestSQLiteStorage:
|
||||
"""Test SQLiteStorage backend."""
|
||||
|
||||
def test_all_fields_round_trip_through_sqlite(self):
|
||||
"""Regression test: every ProvenanceEntry field, including issue #825
|
||||
Part A and Part B additions, must survive a SQLite store/retrieve
|
||||
round trip byte-for-byte. Part B's activity/actedOnBehalfOf/
|
||||
informedBy/revision/bundle fields were initially added to the
|
||||
dataclass and to export_prov() without updating SQLiteStorage's DDL/
|
||||
INSERT/_row_to_entry — InMemoryStorage stores the dataclass directly
|
||||
so it masked the gap, but SQLite silently dropped every one of those
|
||||
fields on write."""
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".db") as tmp:
|
||||
db_path = tmp.name
|
||||
|
||||
try:
|
||||
storage = SQLiteStorage(db_path)
|
||||
entry = ProvenanceEntry(
|
||||
entity_id="entity_1",
|
||||
entity_type="entity",
|
||||
activity_id="extraction",
|
||||
agent_id="alice",
|
||||
agent_type="person",
|
||||
is_automated=False,
|
||||
role="approver",
|
||||
sequence_id=1,
|
||||
previous_checksum="prevchk",
|
||||
parent_entity_id="parent_1",
|
||||
used_entities=["u1", "u2"],
|
||||
previous_version_id="entity_1:v:1",
|
||||
derived_from_id="source_1",
|
||||
invalidated=True,
|
||||
invalidated_at_time="2026-01-01T00:00:00",
|
||||
invalidated_by="reviewer_jane",
|
||||
invalidation_reason="retracted",
|
||||
activity_started_at_time="2026-01-01T00:00:00",
|
||||
activity_ended_at_time="2026-01-01T00:00:05",
|
||||
acted_on_behalf_of="org1",
|
||||
informed_by_activities=["act_a", "act_b"],
|
||||
valid_from="2026-01-01",
|
||||
valid_until="2026-06-01",
|
||||
revision_type="correction",
|
||||
supersedes="old_claim",
|
||||
bundle_id="ingestion_run_1",
|
||||
)
|
||||
storage.store(entry)
|
||||
retrieved = storage.retrieve("entity_1")
|
||||
|
||||
assert retrieved == entry
|
||||
finally:
|
||||
if os.path.exists(db_path):
|
||||
os.unlink(db_path)
|
||||
|
||||
def test_store_and_retrieve(self):
|
||||
"""Test storing and retrieving entries."""
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".db") as tmp:
|
||||
@@ -216,3 +322,66 @@ class TestSQLiteStorage:
|
||||
finally:
|
||||
if os.path.exists(db_path):
|
||||
os.unlink(db_path)
|
||||
|
||||
def test_get_chain_head(self):
|
||||
"""Issue #825, Part A item 2 — chain head reporting."""
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".db") as tmp:
|
||||
db_path = tmp.name
|
||||
|
||||
try:
|
||||
storage = SQLiteStorage(db_path)
|
||||
assert storage.get_chain_head() is None
|
||||
|
||||
entry1 = ProvenanceEntry(
|
||||
entity_id="e1", entity_type="entity", activity_id="act",
|
||||
sequence_id=1, checksum="checksum_1",
|
||||
)
|
||||
storage.store(entry1)
|
||||
assert storage.get_chain_head() == (1, "checksum_1")
|
||||
|
||||
entry2 = ProvenanceEntry(
|
||||
entity_id="e2", entity_type="entity", activity_id="act",
|
||||
sequence_id=2, checksum="checksum_2",
|
||||
)
|
||||
storage.store(entry2)
|
||||
assert storage.get_chain_head() == (2, "checksum_2")
|
||||
finally:
|
||||
if os.path.exists(db_path):
|
||||
os.unlink(db_path)
|
||||
|
||||
def test_trace_descendants(self):
|
||||
"""Issue #825, Part A item 5 — reverse (downstream) lineage traversal."""
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".db") as tmp:
|
||||
db_path = tmp.name
|
||||
|
||||
try:
|
||||
storage = SQLiteStorage(db_path)
|
||||
storage.store(ProvenanceEntry(entity_id="a", entity_type="entity", activity_id="act"))
|
||||
storage.store(ProvenanceEntry(
|
||||
entity_id="b", entity_type="entity", activity_id="act", parent_entity_id="a"
|
||||
))
|
||||
storage.store(ProvenanceEntry(
|
||||
entity_id="c", entity_type="entity", activity_id="act", parent_entity_id="b"
|
||||
))
|
||||
storage.store(ProvenanceEntry(
|
||||
entity_id="d", entity_type="entity", activity_id="act", used_entities=["a"]
|
||||
))
|
||||
|
||||
descendants = storage.trace_descendants("a")
|
||||
entity_ids = {e.entity_id for e in descendants}
|
||||
assert entity_ids == {"b", "c", "d"}
|
||||
finally:
|
||||
if os.path.exists(db_path):
|
||||
os.unlink(db_path)
|
||||
|
||||
def test_trace_descendants_empty_for_leaf(self):
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".db") as tmp:
|
||||
db_path = tmp.name
|
||||
|
||||
try:
|
||||
storage = SQLiteStorage(db_path)
|
||||
storage.store(ProvenanceEntry(entity_id="leaf", entity_type="entity", activity_id="act"))
|
||||
assert storage.trace_descendants("leaf") == []
|
||||
finally:
|
||||
if os.path.exists(db_path):
|
||||
os.unlink(db_path)
|
||||
|
||||
Reference in New Issue
Block a user