M3 implementada e backlog atualizado

This commit is contained in:
Marcos Lohmann
2026-09-14 10:11:29 -03:00
parent f3efcc3414
commit c894c1604b
65 changed files with 4261 additions and 74 deletions

View File

@@ -0,0 +1,135 @@
"""Data-governance guardrails for Profile Intelligence (EP-09-T03, README.md
§6.10, Threat: Sensitive attribute leakage §28).
Three requirements from README §6.10's governance list, made concrete and
testable: pseudonymized subject identifiers, a lineage record per resolution
(features/version/window), and a drift-monitor *stub* -- explicitly not real
statistical drift detection (P7: no real dataset to detect drift against in
this RI), just the extension point a production fork would wire a real one
into.
"""
from __future__ import annotations
import hashlib
from dataclasses import dataclass, field
from datetime import UTC, datetime
from emcp_bus.profile_intelligence.models import ProfileView
_SENSITIVE_ATTRIBUTE_KEY_MARKERS = (
"email",
"cpf",
"ssn",
"phone",
"name",
"address",
"birthdate",
"document",
)
def pseudonymize_subject(raw_identifier: str) -> str:
"""A raw identifier (customer id, email, msisdn, ...) is never carried
into a `ProfileView` -- only this deterministic pseudonym is."""
digest = hashlib.sha256(raw_identifier.encode("utf-8")).hexdigest()
return f"subject:{digest[:6]}"
class SensitiveAttributeLeakageError(Exception):
"""A `ProfileView` carries a raw (non-pseudonymized) subject reference,
or an `attributes` key that looks like direct PII -- README.md §28's
"Sensitive attribute leakage" threat, made a hard failure rather than a
lint warning."""
def assert_no_raw_pii(profile_view: ProfileView) -> None:
if not profile_view.subject_ref.startswith("subject:"):
raise SensitiveAttributeLeakageError(
f"ProfileView.subject_ref {profile_view.subject_ref!r} is not pseudonymized "
"(expected 'subject:<hash>', see pseudonymize_subject)"
)
for key in profile_view.attributes:
lowered = key.lower()
if any(marker in lowered for marker in _SENSITIVE_ATTRIBUTE_KEY_MARKERS):
raise SensitiveAttributeLeakageError(
f"ProfileView.attributes key {key!r} looks like direct PII, not a "
"derived/synthetic attribute (README.md §28)"
)
@dataclass(frozen=True)
class FeatureLineageRecord:
"""Documents *why* a ProfileView looks the way it does -- README.md
§6.10: "documentar features, finalidade, janela temporal e qualidade"."""
subject_ref: str
profile_version: str
features: tuple[str, ...]
purpose: str
time_window: str
resolved_at: datetime = field(default_factory=lambda: datetime.now(UTC))
class FeatureLineageLog:
"""In-memory append-only lineage log -- a JSONL/DB-backed sink is a
natural production extension (same shape as `emcp_bus.audit.sink`), not
needed for this RI's demonstration scope."""
def __init__(self) -> None:
self.records: list[FeatureLineageRecord] = []
def record(
self,
profile_view: ProfileView,
*,
features: tuple[str, ...],
purpose: str,
time_window: str,
) -> FeatureLineageRecord:
entry = FeatureLineageRecord(
subject_ref=profile_view.subject_ref,
profile_version=profile_view.profile_version,
features=features,
purpose=purpose,
time_window=time_window,
)
self.records.append(entry)
return entry
@dataclass(frozen=True)
class DriftReport:
segment_id: str
baseline_score: float
observed_score: float
drifted: bool
class DriftMonitor:
"""Stub (README.md §6.10: "monitorar drift, estabilidade e impacto
desigual") -- flags a segment whose score moved further than
`threshold` from a caller-supplied baseline. Not real statistical drift
detection (no population-level distribution tracking); a production
fork with real telemetry would replace this, not extend it.
"""
def __init__(self, *, threshold: float = 0.3) -> None:
self._threshold = threshold
def check(self, profile_view: ProfileView, baseline: dict[str, float]) -> list[DriftReport]:
reports = []
for segment in profile_view.segments:
baseline_score = baseline.get(segment.id)
if baseline_score is None:
continue
delta = abs(segment.score - baseline_score)
reports.append(
DriftReport(
segment_id=segment.id,
baseline_score=baseline_score,
observed_score=segment.score,
drifted=delta > self._threshold,
)
)
return reports

View File

@@ -0,0 +1,38 @@
"""ProfileView contract (EP-09-T01, README.md §6.10, Axiom 11).
Field names and shape follow the README §6.10 example exactly (`subjectRef`,
`profileVersion`, `segments`, `attributes`, `reasonCodes`, `expiresAt`) --
"Os nomes acima são ilustrativos. A arquitetura não prescreve taxonomia,
algoritmo, número de clusters ou atributos específicos", so this model is
deliberately generic: any `ProfileIntelligenceProvider` implementation
(rule-based here, EP-09-T02; a real clustering/ML model in a production
fork) produces the same contract. A `ProfileView` only ever *orders or
reduces* candidates for the Offering Filter (EP-10) -- it is never consulted
by the PDP (EP-03) and can never grant a capability outside
MaximumEntitlement (Axiom 11): nothing in this module, or anything that
consumes it, is wired into the authorization path.
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class Segment(BaseModel):
id: str
score: float = Field(ge=0.0, le=1.0)
class ProfileView(BaseModel):
subject_ref: str = Field(
description="Pseudonymized subject identifier, e.g. 'subject:7f31c2' -- "
"never a raw PII identifier (README.md §6.10 governance requirements, "
"EP-09-T03's guardrail)."
)
profile_version: str
segments: list[Segment] = Field(default_factory=list)
attributes: dict[str, str] = Field(default_factory=dict)
reason_codes: list[str] = Field(default_factory=list)
expires_at: datetime

View File

@@ -0,0 +1,77 @@
"""Reference `ProfileIntelligenceProvider` (EP-09-T02, README.md §6.10, P7).
A deterministic, rules-over-synthetic-attributes stub -- not clustering, not
a trained model, not a proprietary taxonomy (P7: this RI does not attempt
"real" clustering without a real dataset, see docs/RI-PLANNING.md's gap
resolution). `ProfileIntelligenceProvider` is a `Protocol` so a production
fork can swap in a real ML-backed implementation without touching any
consumer (the Offering Filter, EP-10) -- the contract is `ProfileView` in,
`ProfileView` out.
Determinism (EP-09-T02 acceptance criterion: same subject_ref + same time
window -> same profileVersion) comes from hashing `(subject_ref, window)`
rather than anything time-of-call-dependent -- two resolutions in the same
ISO week for the same subject always agree, which is what a demo needs to
look "sensible" across repeated calls without a real model or a database of
prior scores.
"""
from __future__ import annotations
import hashlib
from datetime import UTC, datetime, timedelta
from typing import Protocol
from emcp_bus.profile_intelligence.models import ProfileView, Segment
_ENGAGEMENT_BANDS = ("low", "medium", "high")
_CHANNEL_POOLS = (["app"], ["app", "email"], ["email", "sms"], ["app", "sms"])
class ProfileIntelligenceProvider(Protocol):
def resolve(self, subject_ref: str, *, as_of: datetime | None = None) -> ProfileView: ...
class RuleBasedProfileProvider:
"""Reference implementation: `Segment` scores and `attributes` are
derived purely from a hash of `(subject_ref, iso_week)` -- no external
data source, no learned parameters. Good enough to demonstrate the
contract (EP-10's Offering Filter, EP-11's DecisionContext) without
claiming any real personalization intelligence.
"""
def __init__(self, *, ttl: timedelta = timedelta(hours=24)) -> None:
self._ttl = ttl
def resolve(self, subject_ref: str, *, as_of: datetime | None = None) -> ProfileView:
as_of = as_of or datetime.now(UTC)
iso_year, iso_week, _ = as_of.isocalendar()
window = f"{iso_year}-W{iso_week:02d}"
digest = hashlib.sha256(f"{subject_ref}:{window}".encode()).hexdigest()
engagement_band = _ENGAGEMENT_BANDS[int(digest[0], 16) % len(_ENGAGEMENT_BANDS)]
preferred_channels = _CHANNEL_POOLS[int(digest[1], 16) % len(_CHANNEL_POOLS)]
low_engagement_score = int(digest[2:4], 16) / 255
digital_preference_score = int(digest[4:6], 16) / 255
reason_codes = []
if low_engagement_score > 0.5:
reason_codes.append("LOW_RECENCY")
if "app" in preferred_channels:
reason_codes.append("APP_AFFINITY")
return ProfileView(
subject_ref=subject_ref,
profile_version=f"rule-based-{window}-{digest[:8]}",
segments=[
Segment(id="low-engagement", score=round(low_engagement_score, 2)),
Segment(id="digital-preference", score=round(digital_preference_score, 2)),
],
attributes={
"relationshipStage": "activation",
"preferredChannels": ",".join(preferred_channels),
"engagementBand": engagement_band,
},
reason_codes=reason_codes,
expires_at=as_of + self._ttl,
)