Plugging in tools you already run

August 30, 2026 ยท View on GitHub

Adapters convert outputs from existing evaluation tools into the canonical metric names read by GOPAL policies. Use an adapter when the measurement already exists and only the data shape needs to be translated.

Every adapter is a pure function of one argument. It does not import the tool it adapts, so a saved Detoxify result can be converted without installing Detoxify. Adapters therefore do not add dependencies for users who do not call them.

from aicertify.adapters import from_detoxify, from_model_card

The return value is always a GOPAL input fragment, the same JSON you would write by hand. Merge it into a contract's context, or dump it to a file and run opa eval against it directly.

Detoxify

Detoxify is the most widely used open toxicity classifier.

from detoxify import Detoxify
from aicertify.adapters import from_detoxify

outputs = [turn["output_text"] for turn in interactions]
fragment = from_detoxify(Detoxify("original").predict(outputs))
{"metrics": {"toxicity": {
  "score": 0.317, "max_toxicity": 0.92, "sample_size": 3,
  "categories": {"insult": {"score": 0.27, "max": 0.79}, "...": {}}
}}}

The adapter emits two toxicity statistics because GOPAL evaluates them separately. metrics.toxicity.score is the aggregate, compared against 0.1, while metrics.toxicity.max_toxicity is the maximum per-output score, compared against 0.7. In the example above the aggregate is 0.317 and the maximum is 0.92. Retaining both prevents an aggregate from obscuring an individual high-toxicity output.

predict() returns a list per class for a list of texts and a NumPy scalar per class for a single string. Both shapes are accepted. NumPy floating-point values are converted to native Python values before the fragment reaches OPA.

The five other Detoxify classes have no canonical GOPAL name. They are kept under metrics.toxicity.categories rather than dropped.

Hugging Face model cards

Model cards are widely used to document AI models, but their structure and scope differ from a compliance evidence record. This adapter maps model-card content into the documentation metrics that GOPAL policies can evaluate.

from huggingface_hub import ModelCard
from aicertify.adapters import from_model_card

fragment = from_model_card(ModelCard.load("bert-base-uncased").content)

The adapter produces two groups of output.

Documentation sections. GOPAL's global/v1/documentation/model_card_score scores nine sections from Mitchell et al., Model Cards for Model Reporting, each made of named subsections. Hugging Face cards use none of those names. They use the current template (Uses, Bias, Risks, and Limitations, Training Details), or the older convention that most high-download repositories still carry (Intended uses & limitations, Limitations and bias, Training data), or something their author invented. The adapter maps all three onto the subsections they genuinely establish.

Reported results. model-index frontmatter is a structured record of benchmark results. These land under metrics.reported.*, deliberately not under metrics.accuracy.score. A self-reported benchmark result describes the reported dataset and evaluation configuration; it does not establish the accuracy of the deployed system. Keeping it under metrics.reported.* preserves that distinction.

One command

$ aicertify score-card bert-base-uncased

bert-base-uncased
  completeness  0.49   (threshold 0.8)
  quality       0.66
  BELOW THRESHOLD

    intended_use             1.00  ####################
    training_data            0.67  #############
    factors                  0.50  ##########
    ...
    evaluation_data          0.00

scored with aicertify 0.7.0, gopal 1.3.1, rubric v1

The version line records the implementations needed to reproduce the score. If a later run produces a different result, the recorded AICertify and GOPAL versions allow a changed rubric to be distinguished from a change in the input. GOPAL is recorded because the rubric is implemented as a GOPAL policy.

--file scores a local README.md, --json is machine-readable, and --threshold compares against something other than 0.8.

Where the rubric lives

The rubric is defined in GOPAL rather than in the adapter. Requirements about which sections are expected, how they are weighted, and what counts as content are policy decisions, so they are implemented as a Rego policy: global/v1/documentation/model_card_score.

score_model_card invokes opa and reads the policy result. The playground runs the same policy compiled to WebAssembly. Both surfaces therefore use the same scoring rules rather than separate implementations of the rubric.

opa is therefore required. If the binary is unavailable, score_model_card raises GopalUnavailable rather than returning an approximate score under the same metric name.

The heading table that maps card headings onto those sections is also loaded from the policy through load_heading_sources(). Keeping the mapping and the scoring rubric in the same policy prevents independent parser copies from diverging.

What real cards actually score

Against the 0.8 threshold GOPAL's EU AI Act technical-documentation check applies:

CardCompletenessQualityPasses 0.8?
bert-base-uncased0.490.66No
openai-community/gpt20.490.66No
distilbert-base-uncased-finetuned-sst-2-english0.410.42No
HuggingFaceTB/SmolLM2-135M-Instruct0.170.31No
sentence-transformers/all-MiniLM-L6-v20.160.19No

None of these cards reaches the 0.8 threshold. The result reflects a scope difference rather than a defect in the cards: a model card can supply part of the documentation requested by Annex IV without covering the complete set of fields. See GOPAL's field-by-field accounting.

The subsections a card cannot fill are left unfilled rather than guessed at. Decision thresholds, intersectional results and the motivation behind an evaluation set are almost never in a card, and filling them with prose from somewhere else would raise the score without raising the documentation.

Fairlearn

Fairlearn is the standard fairness toolkit for Python.

from fairlearn.metrics import MetricFrame, selection_rate
from aicertify.adapters import from_fairlearn

frame = MetricFrame(metrics=selection_rate, y_true=y, y_pred=pred,
                    sensitive_features=group)
fragment = from_fairlearn(frame)
{"metrics": {"fairness": {
  "score": 0.667, "basis": "ratio", "ratio": 0.667,
  "by_group": {"a": 0.4, "b": 0.6}
}}}

Metric direction

GOPAL compares metrics.fairness.score with >=, so higher values must represent better outcomes. Fairlearn's difference() uses the opposite direction, with 0 as its best value. Passing that value through unchanged would invert the meaning of the policy threshold.

ratio() is therefore preferred. It is bounded on [0, 1] with 1 as the ideal, which matches the direction expected by a >= 0.85 threshold and the ratio form used by the four-fifths rule in fair lending. A difference= is accepted and converted to 1 - difference, with basis recording which way the number was turned.

Both MetricFrame result shapes are supported. A multi-metric frame returns a Series keyed by metric name, while a single-metric frame returns bare scalars; the adapter handles both explicitly.

Against healthcare/v1/diagnostic_safety, which gates at 0.85:

Ratiofairness_passesfairness_eval_fails
0.6667undefinedtrue
0.95trueundefined
nothing suppliedundefinedtrue

Perspective API

Jigsaw's Perspective API returns a summary score per attribute per comment, higher being worse.

from aicertify.adapters import from_perspective

scored = [client.comments().analyze(body=req).execute() for req in requests]
fragment = from_perspective(scored)

The mapping matches Detoxify's, because GOPAL asks the same two questions of any toxicity measurement: the aggregate against 0.1 and the worst single output against 0.7.

Perspective omits summaryScore for a language it does not support. That attribute is omitted rather than converted to 0.0, because an unavailable measurement must not be represented as a clean result.

This adapter is implemented against Perspective's documented response schema rather than live integration tests because the repository does not hold a Perspective API key. The documented schema is stable, but this provides weaker validation than testing against live API responses; that limitation is stated explicitly here.

Using a fragment

Into a contract, where the evaluators will also run:

from aicertify.models.contract import create_contract

contract = create_contract(
    application_name="my-app",
    model_info={"model_name": "bert-base-uncased"},
    interactions=interactions,
    context=fragment,
)

Or straight to OPA, with no AICertify in the loop at all:

import json
json.dump(fragment, open("metrics.json", "w"))
opa eval -d gopal/international/eu_ai_act/v1 -d gopal/global -d gopal/helper_functions \
         -i metrics.json --format raw \
         'data.international.eu_ai_act.v1.documentation.technical_documentation.completeness_sufficient'

GOPAL's Plug your evaluator into GOPAL covers that second path, including the same mappings written as plain JSON for people not using Python.

Writing another one

An adapter is a function from a tool's output to a fragment. There is no base class and nothing to register.

def from_your_tool(report: dict) -> dict:
    scores = [r["toxicity"] for r in report.get("results", [])]
    if not scores:
        return {}
    return {"metrics": {"toxicity": {
        "score": sum(scores) / len(scores),
        "max_toxicity": max(scores),
    }}}

Three constraints apply to new adapters.

Do not convert absence to zero. If no measurement is available, return an empty fragment rather than a default score. A default numeric value would cause policy evaluation to treat an unmeasured system as if a measurement had been performed.

Use a metric name that describes the measurement. Do not map a documentation count onto metrics.patient_safety.score because it is the nearest available field. GOPAL interprets that field as a clinical measurement and applies a 0.95 threshold.

Keep statistics apart. An average and a maximum answer different questions. If your tool gives you both, emit both.

If a measurement must be executed rather than an existing result converted, implement an evaluator instead; see Writing an evaluator.