Writing an evaluator

August 30, 2026 ยท View on GitHub

A GOPAL policy can read declared facts supplied about the system or its operating context and measured metrics produced by an evaluation tool. An evaluator supplies measured values in the canonical input fields expected by the policy.

This guide describes how to implement and register an AICertify evaluator.

GOPAL does not require AICertify. A shell script can write the expected JSON and invoke opa directly; GOPAL's Plug your evaluator into GOPAL documents that path. AICertify adds evaluator base classes, discovery, metric-gap reporting, registration, and delivery of measured values into the OPA input shape.

1. Find out what is missing

python scripts/metric_gap_report.py
global  (4/4 measured metrics have an evaluator)
  ok   content_safety.toxicity_score                  ContentSafetyEvaluator
  calc metrics.model_card.completeness                computed by model_card_score
  ok   evaluation.toxicity_score                      ContentSafetyEvaluator
  ok   governance.audit_logging.completeness_score    AuditLoggingEvaluator

industry_specific/healthcare  (3/6 measured metrics have an evaluator)
  GAP  evaluation.clinical_validation.score           no evaluator declares this
  GAP  evaluation.patient_safety.score                no evaluator declares this
  GAP  evaluation.risk_assessment.score               no evaluator declares this

international/eu_ai_act  (12/13 measured metrics have an evaluator)
  GAP  metrics.model_card.compliance_level            no evaluator declares this

TOTAL: 22 of 26 measured metrics can be supplied today.

Each GAP identifies a metric required by a policy for which no registered evaluator currently declares coverage. Use --framework eu_ai_act to restrict the report or --json for machine-readable output.

2. Write the evaluator

An evaluator requires three elements: subclass BaseEvaluator, declare the metrics it supplies, and implement evaluate.

from aicertify.evaluators.base_evaluator import BaseEvaluator, EvaluationResult

class AuditLoggingEvaluator(BaseEvaluator):
    SUPPORTED_METRICS = (
        "metrics.audit_logging.completeness",
        "governance.audit_logging.completeness_score",
    )

    def _initialize(self) -> None:
        self.threshold = float(self.config.get("threshold", 0.8))

    def evaluate(self, data: dict) -> EvaluationResult:
        block = (data.get("governance") or {}).get("audit_logging") or {}
        expected = ("enabled", "retention_period_days", "records_access",
                    "records_changes", "tamper_evident")
        present = [f for f in expected if block.get(f) not in (None, "")]
        score = len(present) / len(expected)

        return EvaluationResult(
            evaluator_name="AuditLoggingEvaluator",
            compliant=score >= self.threshold,
            score=score,
            threshold=self.threshold,
            reason=f"{len(present)} of {len(expected)} audit-logging facts present",
            details={"metrics": {"audit_logging": {"completeness": score}}},
        )

    async def evaluate_async(self, data: dict) -> EvaluationResult:
        return self.evaluate(data)

The implementation in aicertify/evaluators/audit_logging_evaluator.py follows this pattern. It requires no model, API key, or inference; it computes a completeness score from the audit-logging fields present in the contract.

Use GOPAL's canonical metric names in SUPPORTED_METRICS. The gap report matches evaluators to policy requirements by field name. A non-canonical name therefore does not satisfy the policy requirement. GOPAL's helper_functions/metrics.rego defines the canonical names and supported historical aliases.

3. Wire it in

One line, in ComplianceEvaluator.EVALUATOR_CLASSES:

EVALUATOR_CLASSES = {
    ...
    "audit_logging": AuditLoggingEvaluator,
}

Registration is separate from metric declaration. The gap report reads SUPPORTED_METRICS, while ComplianceEvaluator instantiates only evaluators listed in EVALUATOR_CLASSES. An evaluator can therefore declare coverage but remain unavailable at runtime if it is not registered.

The gap report distinguishes this state with WIRE. Without registration, the row reads:

  WIRE governance.audit_logging.completeness_score    AuditLoggingEvaluator declares
       this but is not in ComplianceEvaluator.EVALUATOR_CLASSES, so it never runs

Add the line and it reads:

global  (4/4 measured metrics have an evaluator)
  ok   governance.audit_logging.completeness_score    AuditLoggingEvaluator

WIRE does not count toward runtime metric coverage because the evaluator is not instantiated during evaluation.

4. Publish under the canonical name

Registration controls whether the evaluator runs; metric delivery is a separate step.

GOPAL reads measured metrics at input.metrics.<domain>.<name>, while raw evaluator results are keyed by evaluator name as results.<evaluator>. Those names are not generally equivalent, so evaluator output must be published explicitly under the canonical metric path read by GOPAL.

Publish the metric under details["metrics"] in the shape read by GOPAL:

return EvaluationResult(
    ...,
    details={"metrics": {"audit_logging": {"completeness": score}}},
)

attach_measured_metrics merges each evaluator's metric block into the OPA input. SUPPORTED_METRICS declares which metrics the evaluator can provide; details["metrics"] carries the values provided by a specific evaluation.

tests/test_metric_delivery.py checks the delivery path with opa eval against GOPAL's resolver. A regression that prevents a metric from reaching the policy therefore fails a test.

Metric semantics

Represent missing measurements as missing. If a metric cannot be computed, do not substitute a default numeric value. Returning 0.0 for an unavailable toxicity measurement would cause policy evaluation to treat an unmeasured system as if it had received a clean score.

Match the policy's metric direction and statistic. A safety score where higher is better is not interchangeable with a toxicity score where higher is worse. A maximum and an average also represent different properties. GOPAL therefore keeps metrics.toxicity.score and metrics.toxicity.max_toxicity separate and applies different thresholds to them.

Using it in CI

Once the metric is supplied, the policy that reads it becomes a check that can fail a pull request:

- name: Evaluate compliance
  run: aicertify evaluate --contract contract.json --policy eu_ai_act

Before implementing an evaluator, use aicertify explain <framework> to inspect the required inputs and aicertify init-contract --policy <framework> to scaffold the declared fields. Together with the metric-gap report, these commands expose the required input paths without manual inspection of each Rego file.

The evaluators run, their metrics are written into the contract, and the gopal bundle is evaluated against the result. A policy that is not satisfied fails the step, naming the article and the control. See gopal's GitHub Actions example.

Testing yours

tests/test_audit_logging_evaluator.py provides a compact test pattern. In particular, preserve the distinction between an explicit negative value and a missing value:

def test_a_negative_answer_still_counts_as_answered(self, evaluator):
    """
    "tamper evident: no" is a real answer. Treating False as unanswered
    would drop it from the denominator and flatter the score.
    """

An explicit negative answer must remain distinct from an absent answer; otherwise the denominator and resulting completeness score can be incorrect.

The four metrics still showing GAP

Three are clinical: patient_safety, clinical_validation, and risk_assessment. GOPAL interprets these as measurements from clinical evaluation. A documentation-completeness count is not equivalent to a clinical patient-safety score and must not be published under that metric name. Supply these metrics from the evaluation that produced them or leave them absent so the policy can fail closed.

The fourth is metrics.model_card.compliance_level. It was declared by an evaluator that never computed it, and nothing GOPAL derives from a card yields a compliance level distinct from completeness, so the declaration was removed rather than backfilled.

The other model-card metrics are not gaps and no longer need an evaluator at all. global/v1/documentation/model_card_score computes them from the card itself, and declares as much in a ProvidedMetrics block, so the report marks them calc rather than asking anyone to write tooling for work already done:

calc metrics.model_card.completeness    computed by global.v1.documentation.model_card_score

When a value is a policy judgement over supplied documentation rather than a measurement of running-system behavior, implement that derivation as a policy rule rather than as an evaluator.