Skip to article

Procurement data · Evaluation pack

Test a procurement data feed before building around it.

Run procurement data quality checks on a pinned public sample. Configure acceptance rules, inspect expected failures and verify a repeatable SQLite import without signup.

Published September 11, 20269 min readBy Daniel

Procurement data quality checks should end with evidence that the data can do your job. A readable CSV is a start. The next questions are whether its identifiers remain stable, its child records link correctly, required fields are populated and a second import preserves the result.

This evaluation pack gives you a pinned public sample, a schema contract, configurable acceptance rules and a working SQLite import. It runs locally without credentials, signup or an email gate. The same pack produces both a passing historical assessment and two useful rejection reports.

Tested September 11, 2026: the historical run imports 217 notice versions and 664 CPV references, verifies every stored payload and relationship after two identical imports, and passes 12 automated tests. The sample is from the September 1 edition; it is deliberately not described as a current opportunity feed.

Define the job before setting a pass mark

The default job is reviewing a historical sample of IT-related public procurement notices in a local database. It requires titles, buyer names and stages on every row, accepts source links on at least 95% of rows, and sets a 24-hour maximum package age at the assessment time.

Those tolerances are explicit example decisions. They are not an industry benchmark or a recommendation for an alerts product. A workflow that must take every user to an official notice may require 100% source links. A bid-deadline workflow needs stage-specific deadline validity and timezone checks, not just nonempty fields.

The Open Contracting Partnership distinguishes field, record, collection and time-based checks in its description of Pelican. That distinction is useful here: structural validity alone cannot decide fitness for your application. This pack is a narrow CSV acceptance example, not Pelican, OCDS certification or an implementation of every procurement quality rule.

Inspect the pinned sample and schema

The pack reuses the public sample behind the tender API comparison and Power BI walkthrough. It selected 217 rows with at least one CPV code beginning 72 from a single daily file. It contains TED, Find a Tender and Contracts Finder records across multiple procurement stages.

The receipt identifies the September 1, 2026 edition, generated September 2 at 13:25:55 UTC, and links the tagged source release. source.csv preserves the sample bytes. The evaluator verifies this SHA-256 before importing:

0f843a15757d82f530d49ed8cfbf8a322e31a67f568ee4a7cbe41310cfd0764f

schema.json declares the exact ordered headers and the composite notice key: source + source ID + source release ID. A row is one source notice version. It is not automatically a unique procurement, award, supplier or live opportunity.

The companion notice-cpvs.csv has one row for each supplied CPV array occurrence, retaining the parent key, array ordinal and code. Its 664 references are derived from the sample's own cpv_codes arrays. Comparing them with the source tests whether the conversion preserved those relationships; it does not independently verify the publisher's classifications.

The evaluator preserves other source values as text. It checks CPV JSON structure and eight-digit syntax, but does not check membership in an official CPV code list. The source receipt retains publisher licence references.

Run the checks and complete an import

Extract the ZIP and open a terminal in its folder. Use Python 3.10 or later; everything else comes from the standard library.

python -B evaluate.py --as-of 2026-09-02T14:00:00Z --output-dir my-historical-run
python -B -m unittest test_evaluate.py

The explicit assessment time makes the historical run reproducible. It is about 34 minutes after the package was generated, so the 24-hour age rule passes. Omitting --as-of uses the current UTC clock instead.

The successful run prints accepted: true and imported: true, writes report.json, and creates evaluation.sqlite. Choose a new output directory for every run; the script rejects existing folders to prevent mixing results.

The database has two tables:

| Table | Stored rows | Meaning | |---|---:|---| | notices | 217 | Composite notice key plus the complete CSV row as JSON text | | notice_cpvs | 664 | Notice key, array ordinal and CPV code |

You can inspect it with any SQLite client:

SELECT COUNT(*) FROM notices;
SELECT COUNT(*) FROM notice_cpvs;
PRAGMA foreign_key_check;

The counts should be 217 and 664, and the foreign-key query should return no errors. The runner also compares all stored payloads and relationships with the inputs, so matching totals alone are not enough to pass.

Change the rules to match your workflow

The supplied acceptance.json contains:

{
  "max_package_age_hours": 24,
  "minimum_presence": {
    "title": 1.0,
    "buyer_name": 1.0,
    "record_stage": 1.0,
    "source_url": 0.95
  }
}

Fractions range from zero to one. Unknown field names and unsupported rule keys are rejected rather than silently ignored. Copy the file, change the thresholds, and pass its path through --config.

Presence means nonempty source text. It does not establish that a URL resolves, a date is correct or an amount has a usable basis. A supplied zero counts as present. The report includes coverage for every column even when that column is not an acceptance requirement.

Keep the rules tied to the consumer's need. If 100% source links are necessary, a 95% result should trigger a decision about missing-link handling or source scope. Lowering the threshold solely to obtain a green report does not resolve the problem.

Read the failures instead of hiding them

Two rejection examples ship with the pack:

| Assessment | Expected result | Reason | |---|---|---| | Historical time, default rules | Accept and import | The specified requirements pass | | September 11, 00:00 UTC, default rules | Reject; no database | The package exceeds 24 hours old | | Historical time, 100% source links | Reject; no database | Seven source links are blank |

Run the current-time check with:

python -B evaluate.py --output-dir my-current-run

The old sample should fail package_age. This is an expected outcome, not a reason to reset the receipt date. Package age is not source amendment latency. The check measures elapsed time since package generation; it does not prove how quickly an official source change reached the package.

Run the stricter link requirement with:

python -B evaluate.py --as-of 2026-09-02T14:00:00Z --config strict-links.json --output-dir my-strict-run

That run fails presence:source_url: 210 of 217 rows have a source link (96.8%). Each normal acceptance failure writes a machine-readable report and exits with code 1. Successful acceptance exits with code 0. Malformed input or configuration can stop before producing a report; treat a missing report as an unsuccessful run too.

Other measured gaps remain visible: 35 rows have a deadline, 111 have a value, and 20 have a source-update timestamp. Their denominators include all stages. These figures do not establish whether a field should exist on each particular notice. Add source- and stage-specific expectations for a real application.

The structural checks require unique notice keys, unique parent-plus-ordinal CPV keys, resolved parents and an exact multiset match between source CPV references and the delivered relationship table. Moving a reference to another valid parent still fails, even if the total row count remains unchanged.

Only after all acceptance checks pass does the runner import the records. SQLite enforces the parent relationship. The runner repeats the same import and compares the ordered contents before and after: there must still be 217 notices, 664 references and identical payloads.

This proves repeatability for the same snapshot. It does not test a real amendment, a removed child relationship, a deletion or out-of-order deliveries. The supplied import routine is not a general snapshot synchronization service. Use the OCDS conversion tutorial for lot, award and supplier relationships, and the HubSpot reference example for source-field updates in a CRM workflow.

The 12 automated tests cover the working import and expected failures, including duplicate notice keys, missing children, unresolved parents, wrong existing parents, schema drift, malformed CPV arrays, unsupported configuration and stale or future-dated packages.

Separate a passing sample from a production feed

The report explicitly lists what it did not test: full source coverage, live links, amendment latency, OCDS conformance, cross-source deduplication, financial additivity and changed-snapshot handling. A passing result only covers its named checks, input and configuration.

Do not infer a complete government-contract market from this selected file. Do not sum notice values just because they imported correctly. Do not interpret a CPV relationship as an award-to-supplier link. The OCDS data-quality guidance is useful when evaluating actual OCDS publication quality; this normalized CSV needs its own contract as well.

To evaluate another delivery, adapt a reviewed copy of the schema, receipt, source pin and relationship checks. The shipped runner intentionally rejects a different source hash. Establish the new input's provenance rather than simply removing that check.

For broader evaluation design, use the proof-of-concept guide and data-quality QA framework. This pack supplies a concrete starting point for that process.

Use the same checks to scope a paid pilot

Run the pack first. Then describe your sources, fields, row meaning, destination and required delivery schedule. Include your acceptance configuration and the failed-check names that matter to the application. A local SQLite pass is useful evidence; it is not a claim that a warehouse, CRM or dashboard integration has already been tested.

The public tender datasets and this pack are available free. The paid pilot concerns an agreed delivery: specified source coverage, mapped fields, checks, destination and operating responsibilities. Recurring service adds maintenance against that scope.

Agree the decision rules before the pilot starts: which failures block delivery, what happens to incomplete records, how a real amendment will be tested, and what constitutes acceptance. The pilot should produce a receipt and an import result your team can review, not a promise that every dataset will pass every threshold.

Frequently asked questions

Do I need to give an email address to run the pack?

No. Download it and run it locally with Python. A paid pilot is a separate, scoped request after you have evaluated the example.

Why does the current-time check fail?

The bundled sample is historical. Its package generation timestamp exceeds the default 24-hour age limit today. Use the documented historical assessment time to reproduce the passing run; do not treat it as a current delivery.

Does a pass certify the feed for production?

No. It verifies the named checks and local import for this exact input. Real amendment handling, source coverage, live availability and your destination's behavior need their own acceptance evidence.