Plugging your evaluator into GOPAL

August 29, 2026 ยท View on GitHub

GOPAL is a Rego library. It takes one JSON document and returns compliance verdicts. Nothing about that requires a particular language, framework or vendor: if your tool can write JSON and you can run opa, you can use GOPAL.

This tutorial builds that integration from scratch, with no dependencies beyond the opa binary. AICertify automates the same thing in Python if you would rather not hand-roll it, and there is a note at the end on when that trade is worth making.

The two kinds of input

Every GOPAL policy reads some mixture of:

Declared facts. Things a person asserts about an organisation or a system. Was a conformity assessment completed. Can a human intervene in an automated decision. Was the CE marking affixed. Nobody can compute these; somebody signs them. Most obligations in the EU AI Act are of this kind.

Measured metrics. Things a tool computes. A toxicity score, a fairness disparity, a model-card completeness score. You cannot assert these honestly, and a policy that reads one you never supply can never be satisfied.

Supplying declared facts is a matter of writing them into the input document. The rest of this tutorial is about the measured half, because that is the part that needs a tool behind it.

1. Find out what a policy reads

Ask the policy, not the documentation:

scripts/extract-input-fields.sh international/eu_ai_act/v1/documentation/technical_documentation.rego
metrics.model_card.completeness
metrics.model_card.compliance_level
metrics.model_card.quality
metrics.model_card.section_scores
params
params.completeness_threshold
params.quality_threshold

This is derived from the parsed policy, not from its comment block, because comment blocks drift. Pass as many files as you like.

Anything under params. is a threshold you may override and can otherwise ignore. Everything else is input you are expected to supply.

2. Use the canonical name

The same number used to be read under several names. Content safety had six spellings. helper_functions/metrics.rego fixes the canonical name as metrics.<domain>.<name> and keeps every historical spelling working as a fallback:

"metrics.toxicity.score": [
	["metrics", "toxicity", "score"],
	["evaluation", "toxicity_score"],
	["content_safety", "toxicity_score"],
],

Supply the canonical name. The legacy ones still resolve, so old inputs keep working, but new code should not add to the pile.

Two entries in that table are worth reading twice:

"metrics.toxicity.score":        # an aggregate, compared against 0.1
"metrics.toxicity.max_toxicity": # the single worst output, compared against 0.7

They are different statistics and are deliberately not merged. Feed a worst-case maximum into a 0.1 threshold and almost any real system fails, which does not make the check stricter, it makes it useless and ignored.

3. Write the document

Your evaluator's job is to produce JSON. Nothing more:

{
  "metrics": {
    "model_card": {
      "completeness": 0.86,
      "quality": 0.81,
      "compliance_level": 0.9,
      "section_scores": {
        "intended_use": 0.9,
        "training_data": 0.85,
        "evaluation_data": 0.6
      }
    }
  }
}

4. Evaluate

opa eval -d international/eu_ai_act/v1/documentation/technical_documentation.rego \
         -d helper_functions/ \
         -i input.json \
         'data.international.eu_ai_act.v1.documentation.technical_documentation'
completeness_sufficient    = true
quality_sufficient         = true
compliance_level_acceptable = true
missing_sections           = ["evaluation_data"]

missing_sections came back populated even though the document passed, which is the point of the library: the verdict and the reasons are separate outputs.

Two flags matter when you scale this up. Pass -d helper_functions/ or the canonical name lookup is not loaded. And if you point -d at the repository root, add --ignore '*.json' --ignore '*.yaml' --ignore '*.yml', because OPA will otherwise try to load every data file in the tree and fail with a merge error that looks nothing like its cause.

5. Fail a build with it

- name: Compliance gate
  run: |
    ok=$(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')
    [ "$ok" = "true" ] || { echo "documentation completeness below threshold"; exit 1; }

Read the value and test it, rather than reaching for --fail or --fail-defined. Those turn on whether a result is defined, and false is perfectly well defined, so a policy that denies exits 0 and the gate waves the build through. They are the right flags for a deny set that is empty when compliant, and the wrong ones for a boolean.

Note the third -d. The EU AI Act policies import global/v1/common, so omitting -d global fails with undefined function data.global.v1.common.fairness.gender_bias_detected rather than anything about a missing directory.

A worked repository is in examples/github-actions.

Two things that will bite you

Absent is not zero. If your evaluator could not compute a metric, leave it out. Do not emit 0.0.

In Rego an undefined value is not false and it is not 0. GOPAL relies on that: resolve returns undefined for a metric nobody supplied, which is a different statement from "measured, came out at zero". On a scale where lower is better, a zero default reports an unmeasured system as perfectly clean, and the policy will believe you.

Where a policy needs absence to be a definite failure it says so explicitly, with a sentinel that compares below every threshold:

score(name, fallback) := metrics.resolve_or(input, sprintf("metrics.%v.score", [name]), fallback)

patient_safety_met if {
	score("patient_safety", -1) >= threshold("patient_safety_threshold", 0.95)
}

That -1 is deliberate. It fails closed and stays counted, rather than dropping out of the assessment as an undefined comparison.

Say which direction your number points, and which statistic it is. A safety score where higher is better and a toxicity score where higher is worse are not interchangeable. GOPAL's is_toxic once answered true for one of the safest possible systems because they had been treated as if they were. An average and a maximum are likewise different questions, which is why the two toxicity entries above exist separately.

Mapping a tool you already run

Most of this is a rename. If you already measure toxicity or fairness, the work is mapping that tool's output onto the canonical names, not measuring anything again.

Two worked mappings, both verified against the real tools rather than their documentation.

Detoxify

Detoxify returns one float per class per input text:

{"toxicity": [0.001, 0.92, 0.03], "insult": [0.0, 0.79, 0.01], "...": []}

Map the toxicity list to both statistics GOPAL asks for:

{"metrics": {"toxicity": {
  "score": 0.317,
  "max_toxicity": 0.92
}}}

score is the mean and max_toxicity is the maximum. In this example the mean sits under the 0.1 threshold it is compared against while the maximum is over the 0.7 the transparency policy uses. Supply only the mean and the one genuinely toxic output has been averaged into invisibility.

Fed to global/v1/toxicity that fragment gives allow = false and deny = true. Change the scores to [0.001, 0.002, 0.003] and allow becomes true. Send {} and allow is false, because an unevaluated system is not a clean one.

Fairlearn

Fairlearn reports the gap between groups two ways: difference(), where 0 means no disparity, and ratio(), where 1 does.

GOPAL compares metrics.fairness.score with >=, so higher is better. Take the ratio:

{"metrics": {"fairness": {"score": 0.67}}}

Take the difference and you must turn it over first, 1 - difference. Supply it unchanged and the fairest possible system scores 0 and fails, while a badly skewed one scores near 1 and passes. This is the same mistake that once had is_toxic answering true for a system with no toxicity in it, and it is silent: every number is in range and every verdict is backwards.

The ratio is the better source anyway. It is already bounded on [0, 1] with 1 as the ideal, which is the shape a >= 0.85 threshold expects, and it is the form the four-fifths rule in fair lending is written in.

Against industry_specific/healthcare/v1/diagnostic_safety, which gates at 0.85: a 0.6667 ratio gives fairness_eval_fails = true, 0.95 gives fairness_passes = true, and supplying nothing fails closed.

Perspective API

Jigsaw's Perspective API returns, per comment, a summary score per attribute:

{"attributeScores": {"TOXICITY": {"summaryScore": {"value": 0.83}}}}

Collect the TOXICITY values across your outputs and map them exactly as for Detoxify above: the mean to metrics.toxicity.score and the maximum to metrics.toxicity.max_toxicity.

Perspective omits summaryScore for a language it does not support. Leave that comment out of the aggregate rather than scoring it 0.0, which would report text nobody could score as clean.

Hugging Face model cards

A model card is a README.md with YAML frontmatter, and it is the most widely published description of an AI system there is. The frontmatter states some things outright:

license: apache-2.0
datasets: [bookcorpus, wikipedia]
model-index:
  - results:
      - metrics:
          - type: accuracy
            value: 0.9105

license and datasets are declared facts and can go straight into the input document. model-index results are worth carrying, but put them somewhere of your own rather than under a canonical name: a self-reported score on a benchmark the author chose is a claim about a dataset, not a measurement of your deployed system, and mapping it to an accuracy metric lets it answer a question nobody asked of it.

Turning the prose sections into a completeness score needs something that reads markdown and scores it. That is an evaluator rather than a mapping, and AICertify ships one.

What a card actually gets you

Running real cards through that evaluator, against the 0.8 threshold the EU AI Act technical-documentation policy applies:

CardCompletenessPasses 0.8?
bert-base-uncased0.49No
openai-community/gpt20.49No
distilbert-base-uncased-finetuned-sst-2-english0.41No
sentence-transformers/all-MiniLM-L6-v20.16No

None passes, and the top two are among the most downloaded models in the world. That is not a failing of the cards. A model card answers part of what Annex IV asks and then stops, which is set out field by field in model-cards-vs-compliance.md.

Useful, and not sufficient. Both halves matter.

Adapters, if you are using Python

AICertify ships these two as functions that return exactly the JSON above, so you do not have to write the mapping yourself:

from aicertify.adapters import from_detoxify, from_model_card

See its adapter guide. The mapping is the same either way; the adapter just saves you typing it.

Some metrics should not come from a tool

Not everything measurable is measurable by you.

metrics.patient_safety.score is gated at 0.95 and is meant to be a clinical measurement. It would be easy to write something that counts how many safety fields a document contains and publishes the result under that name. Do not: a system with complete paperwork would score 1.0 and clear a patient-safety gate it was never assessed against. Supply that number from the clinical evaluation that actually produced it, or leave it absent and let the policy fail closed.

The rule of thumb is that the name has to describe what you measured. If it does not, you are not closing a gap, you are hiding one.

If you would rather not build this

Everything above is the manual path, and it is a supported one: a shell script that writes JSON and calls opa is a complete GOPAL integration.

AICertify does the same work in Python and takes the boilerplate off you: contract scaffolding (aicertify init-contract), a report of which metrics your policies need and which of your evaluators supply them, a base class with the plumbing already written, and the merge that puts your measurements where the policies read them. Its evaluator guide is the same story told in Python.

Use it if you want the scaffolding. Skip it if you already have an evaluation pipeline and just need a compliance verdict out of the other end. GOPAL does not know or care which you chose.

See also