§ 01What the score measures
In Erario a call and a company profile each become a vector, and the cosine between them is what the interface shows as “0,82 fit”. That number is a statement about vocabulary: the text of an industrial-digitalisation programme sits close to a profile that describes a machining SME in Valencia. It is a good statement of that kind. It says nothing about the company's headcount, its CNAE code, where its fiscal domicile is, whether the application window is open, or whether it took de minimis aid last year. None of those facts is in the embedding in a form a threshold can recover, and several of them are not in the company's description of itself either.
The gap is structural, not a matter of tuning. Eligibility in a Spanish or EU call is written as a list of conditions that must all hold: type of beneficiary, size class under Commission Recommendation 2003/361/EC, activity code, territory, an eligible action, a window of dates, and a set of exclusions: undertakings in difficulty, tax or social-security debts, sector exclusions, aid ceilings. A conjunction of seven predicates does not become truer because one paragraph is close to another in embedding space. So the score is demoted to the job it does well: deciding which of several thousand published calls deserve to be evaluated against a profile at all. Rules decide the rest, and every rule carries the passage it came from, which is the same contract as in the note on authoritative sources.
§ 02A rule is a record, not a line of code
Every condition a call imposes is stored as a record of one shape, so that evaluation, explanation and audit read the same data. The record names the profile field it tests, the operator, the value, whether failing it is fatal or only a flag, and, the part that keeps the system honest, the passage in the official document the condition was extracted from. Extraction is schema-constrained: the extractor fills a field with the value the passage states or with null, never with a plausible default. A rule with a null value is not a rule; it is a requirement the reader has to check by hand. reviewed records that a person compared quote and value; valid_from and superseded_by tie the rule to the bulletin issue that introduced it and to the correction that replaced it.
{
"rule_id": "EX-IND-DIG-2026/r04",
"call_id": "EX-IND-DIG-2026",
"field": "cnae.primary",
"op": "in_prefix",
"revision": "CNAE-2009",
"value": ["10","11","13","14","15","16","17","18","19","20","21","22",
"23","24","25","26","27","28","29","30","31","32","33"],
"exclusions": ["12"],
"severity": "hard",
"evidence": {
"doc": "bulletin-2026-04117",
"passage": "art.3.1.a",
"quote": "empresas cuya actividad principal se encuadre en las divisiones 10 a 33 de la CNAE-2009, excepto la 12"
},
"extracted_by": "extractor@2.3.1",
"reviewed": true,
"valid_from": "2026-03-02",
"superseded_by": null
}
Operators are a closed set: eq, in, in_prefix for hierarchical codes, lte and gte for headcount, turnover and dates, within for territory against a hierarchy of NUTS and municipality codes, not_in for exclusions. A closed set keeps the evaluator to a few dozen lines and lets a reviewer read a rule without reading code. Anything a call says that fits no operator, “proyectos de especial interés para la Comunitat”, say, is stored as a manual rule that always evaluates to unknown and surfaces as a requirement to verify, which is what the product view means by “1 requirement to verify”.
§ 03CNAE is a taxonomy, and calls quote it loosely
CNAE-2009 is a four-level hierarchy: a section letter (C, manufacturing), a two-digit division (25, fabricated metal products), a three-digit group (25.6) and a four-digit class (25.62, machining). Calls cite it at any level and in any prose form: “empresas industriales” (section C, divisions 10 to 33), “divisiones 10 a 33 excepto la 12”, an explicit list of classes, or a negative list. A company carries a primary code and often secondaries; the heading it pays tax under (IAE) is a different classification; and the code typed into a profile is often what the company thinks it does rather than what it declared. On top of that the classification has been revised, CNAE-2025 follows NACE Rev. 2.1, so a call written against CNAE-2009 and a profile registered under CNAE-2025 need a correspondence table, and some correspondences are one-to-many.
The evaluator therefore normalises the code, matches by prefix, applies exclusions after inclusions, and treats a code with no unique correspondence in the rule's revision as unknown rather than guessing. This is bookkeeping, and it is worth writing out, because it is where false “eligible” labels come from.
def cnae_match(rule, profile):
"""Three-valued: True, False, or None (unknown). Returns (result, why)."""
cnae = profile.get("cnae") or {}
code = cnae.get("primary") # e.g. "25.62"
if code is None:
return None, "cnae.primary missing"
norm = code.replace(".", "") # "2562"
if cnae.get("revision") != rule["revision"]:
mapped = CORRESPONDENCE.get((cnae.get("revision"), norm))
if not mapped or len(mapped) != 1: # missing, or one-to-many
return None, f"cnae {code} has no unique mapping to {rule['revision']}"
norm = mapped[0]
passage = rule["evidence"]["passage"]
if any(norm.startswith(x) for x in rule.get("exclusions", [])):
return False, f"cnae {code} excluded by {passage}"
if any(norm.startswith(x) for x in rule["value"]):
return True, f"cnae {code} within {passage}"
return False, f"cnae {code} outside {passage}"
§ 04Evaluating with fields missing
A profile is never complete. A company that signed up yesterday has a name, a CNAE and a province; headcount, turnover, balance-sheet total, the aid received over the last three fiscal years and the ownership structure that decides whether it is really an SME arrive later or never. The evaluator uses Kleene's three-valued logic: the conjunction is false as soon as one hard rule is false, true only when every hard rule is true, unknown otherwise. It does not short-circuit on the explanation side: the reader needs every failed and every unresolved condition, not the first one found.
def evaluate(call, profile):
hard_false, unknown, soft_flags, evidence = [], [], [], []
for r in call["rules"]:
ok, why = OPERATORS[r["op"]](r, profile) # True / False / None
evidence.append({"rule": r["rule_id"], "result": ok, "why": why,
"passage": r["evidence"]["passage"]})
if ok is None:
unknown.append(r)
elif ok is False:
(hard_false if r["severity"] == "hard" else soft_flags).append(r)
if hard_false: # Kleene AND: one False wins
verdict = "excluded"
elif unknown: # no False, some None
verdict = "to_verify"
else:
verdict = "eligible"
return {
"verdict": verdict,
"verified": sum(1 for e in evidence if e["result"] is True),
"to_verify": [r["field"] for r in unknown],
"excluded_by": [r["rule_id"] for r in hard_false],
"flags": [r["rule_id"] for r in soft_flags],
"evidence": evidence,
"similarity": call["score"], # kept beside, never mixed in
"ruleset_version": call["ruleset_version"],
}
The verdict to_verify is not a probability, and I resist turning it into one. What can be said honestly about a call with two unresolved conditions is which two, what value would resolve each, and, when the population is large enough, how often that condition fails once the field is supplied. That last figure is a base rate over past evaluations (“headcount, when supplied, exceeds the limit in 6 % of profiles in this size band”) and it belongs next to the field, not inside a score. Fold it in and you get the number the reader will misread: a “0.9 probability of eligibility” that was really 0.82 similarity times a guess.
§ 05Presenting “probably eligible” without lying
The candidate list is sorted in stages: by verdict class first (eligible, then to verify; excluded calls are collapsed and shown on request), by similarity within a class, then by deadline. The similarity is displayed because it explains why the call is on the list; the verdict class says whether to act. Each row shows counts, conditions verified, conditions to verify, the exclusion if there is one, and each count opens the passage in the official document. What a row never shows is a single number that mixes fit with eligibility, and it never says “eligible” while a hard rule is unresolved, however high the similarity.
Two more things keep the label honest. Date rules are evaluated on read, not on ingest, so a call whose window closed overnight is excluded this morning without a re-crawl. And every verdict is stamped with the versions of the rule set and the extractor that produced it, so that when a call is amended, corrections in the bulletin are routine, the profiles evaluated under the old rules are re-evaluated and told what changed. The retrieval note covers how the passages themselves stay addressable; here it is enough that a rule can name one.
§ 06Failure modes
The false “eligible” labels I have seen or built all come from one of five places, and each has a fix at the level of the rule record rather than the threshold.
- Size computed on the applicant alone. The SME definition counts partner and linked enterprises; a 40-person subsidiary of a large group is not an SME. Without ownership data the size rule must evaluate to unknown, not true.
- Territory anchored on the wrong address. Some calls require the fiscal domicile in the region, others the centre of work where the investment happens. A profile with a single postal address passes a rule it should not; the rule record has to say which anchor it tests.
- CNAE from self-description. A company that “does industry 4.0 consulting” and typed 62.02 correctly fails an industrial call and wrongly fails a digitalisation call aimed at its clients' sectors. Declared and self-described codes are held as separate fields with separate provenance.
- Aid ceilings assumed empty. De minimis is counted per single undertaking over three fiscal years. Without an aid history the ceiling rule is unknown; a system that assumes zero prior aid produces confident false positives.
- The stale rule set. Calls are amended and extended; a rule extracted from the original text keeps excluding on an old deadline or including on an old budget until it is re-extracted. The version stamp above is what makes the re-evaluation possible.
§ 07What I measure
The evaluation set is a list of (call, profile) pairs labelled by hand against the official text with one of the three verdicts. Take an illustrative set of 400 pairs, 120 truly eligible, 280 not, built so that 30 % of profiles are missing at least one hard field, and assume the rules in the set were extracted correctly (extraction agreement is measured separately). The semantic-only baseline labels a pair eligible above 0.75 cosine; the rule design labels as above, on the candidates the retriever proposed at k = 50.
| System output | Truly eligible | Truly not eligible | Precision of the label |
|---|---|---|---|
| Semantic ≥ 0.75 → “eligible” | 96 | 88 | 52 % |
| Semantic < 0.75 → “not eligible” | 24 | 192 | 89 % |
| Rules → eligible | 78 | 2 | 98 % |
| Rules → to verify | 38 | 30 | — |
| Rules → excluded | 4 | 248 | 98 % |
The semantic threshold is wrong about half the time when it says eligible, and it has no way to say “I don't know”. The rule design says eligible rarely and is almost always right; the price is a to-verify bucket of 68 pairs (17 %) and the four eligible calls the retriever never proposed, which put a ceiling on recall. So the metrics I track are those: candidate recall at k, which is the only place similarity is scored; precision of the eligible label, which has to stay near one because a false eligible costs an application; the size of the to-verify bucket and which fields drive it, which is the product backlog: ask for the field that unblocks the most calls; extraction agreement against reviewed rules; and the share of verdicts that change on re-evaluation after an amendment. None of these is a similarity number, which is the point.