{% extends "base.html" %} {% from "_macros.html" import rate, score %} {% block title %}{{ run_id }} — bpmn-evalkit{% endblock %} {% block body %}

{{ run_id }}

{% if summary %}

{{ summary.sut_name }} {{ summary.sut_version }} · {{ summary.dataset }} ({{ summary.dataset_version }}, {{ summary.cases }} cases) · k={{ summary.k }} · started {{ summary.started }}

{% endif %}
Status: {{ status.state }} {% if status.get('total') %} {{ status.get('done', 0) }}/{{ status.total }} {% endif %} {% if status.get('error') %}

{{ status.error }}

{% endif %} {% if metrics %}

Markdown report

{% endif %}
{% if aggregates %}

Stage 1 — validity gates

{% for gate in ['gate.parse','gate.xsd','gate.lint','gate.wfnet','gate.soundness','gate.reachability','gate.di'] %} {% set e = aggregates.get(gate) or {} %} {% endfor %}
MetricRatePassedDecidedSkipped
{{ gate }} {{ rate(e.get('rate')) }} {{ e.get('passed', 0) }} {{ e.get('decided', 0) }} {% if e.get('skipped') %}{{ e.skipped }}{% else %}0{% endif %}

Reading soundness: {% for k, v in (aggregates.get('soundness.verdicts') or {}).items() %}{{ k }}: {{ v }}{% if not loop.last %}, {% endif %}{% endfor %}. Models that are not workflow nets — more than one start or end event, which is legitimate BPMN — are reported by gate.wfnet and are never counted as unsound.

{% set s2 = aggregates.get('struct.status') or {} %}

Stage 2 — element similarity

{% if s2.get('status') == 'available' %} {% set match = aggregates.get('struct.match') or {} %}
{% set has_ceiling = 'struct.ceiling.all' in aggregates %} {% if has_ceiling %}{% endif %} {% for bucket, label in [('task','Activities'),('event','Events'),('gateway','Gateways'),('flow','Sequence flows'),('lane','Lanes'),('all','All elements')] %} {% set e = aggregates.get('struct.f1.' + bucket) or {} %} {% if has_ceiling %}{% endif %} {% endfor %}
TypeF1F1 medianPrecisionRecallF1 (parsed only)AttainableHuman ceilingn/a
{{ label }} {{ score(e.get('f1_mean')) }} {{ score((e.get('f1') or {}).get('median')) }} {{ score(e.get('precision_mean')) }} {{ score(e.get('recall_mean')) }} {{ score((e.get('valid_only') or {}).get('f1_mean')) }} {{ score(e.get('attainable_f1_mean')) }}{{ score((aggregates.get('struct.ceiling.' + bucket) or {}).get('f1_mean')) }}{% if e.get('not_applicable') %}{{ e.not_applicable }}{% else %}0{% endif %}
{% if has_ceiling %} {% set refs = aggregates.get('struct.references') or {} %}

Human ceiling — what two gold models of the same description score against each other, over {{ aggregates.get('struct.ceiling.pairs', 0) }} reference pair(s), measured with the same matcher and threshold. {% if refs.get('min_grade') is not none %}Only models graded ≥ {{ refs.min_grade }} counted as gold; a lower threshold would be a strictly easier benchmark.{% endif %} It is a second number to read beside the first — not a target and not a divisor. A system above the ceiling has not beaten the humans; more likely it resembles one reference closely while the references disagree among themselves.

{% endif %}

Matcher {{ match.get('matcher') }}, threshold {{ match.get('threshold') }}. Both are part of the number — a score computed at another threshold is a different score and must not be compared with this one. The F1 column is the mean over all outputs: one that produced nothing scores 0, not "not measured". Where it diverges from the median, the gap is the failure rate. Subtype agreement among matched pairs: {{ rate((aggregates.get('struct.subtype_agreement') or {}).get('rate')) }} — an exclusive gateway matched to a parallel one counts as a match and as a disagreement.

{% else %}

Skipped. {{ s2.get('detail') or 'no gold models available at scoring time' }}. Skipped is not zero and not a pass: nothing was measured.

{% endif %} {% set behav = aggregates.get('behav.status') %} {% if behav %}

Stage 3 — behaviour

{% set has_bceiling = 'behav.ceiling.f1' in aggregates %} {% if has_bceiling %}{% endif %} {% for label, metric in [('Reference behaviour the output admits','behav.footprint.fitness'),('Output behaviour the reference admits','behav.footprint.precision'),('Harmonic mean of the two','behav.footprint.f1')] %} {% set e = aggregates.get(metric) or {} %} {% if has_bceiling %}{% endif %} {% endfor %}
QuestionMetricMeanMedianHuman ceiling
{{ label }} {{ metric }} {{ score(e.get('mean')) }} {{ score(e.get('median')) }}{{ score((aggregates.get(metric.replace('footprint','ceiling')) or {}).get('mean')) }}
{% if has_bceiling %}

The ceiling is the caveat here, and it is severe. Two gold models of the same description score {{ score((aggregates.get('behav.ceiling.f1') or {}).get('mean')) }} against each other over {{ aggregates.get('behav.ceiling.pairs', 0) }} pair(s). A relation needs both endpoints matched, so the label-matching bottleneck compounds quadratically — where humans use different vocabularies this metric is dominated by activity matching rather than by behaviour.

{% endif %}

Compared model to model — the reference's Petri net is the behavioural specification, so no event log and no play-out sampling is involved. Scored on {{ behav.scored }} of {{ behav.total }} outputs {% set skipped = [] %}{% for k, v in (behav.by_status or {}).items() %}{% if k != 'scored' %}{% set _ = skipped.append(k ~ ': ' ~ v) %}{% endif %}{% endfor %} {% if skipped %}({{ skipped | join(', ') }}){% endif %}. Both directions are reported and never read alone: fitness by itself rewards a model that permits everything, precision by itself one that permits almost nothing. An unconvertible model is counted, never scored 0 — that is about pm4py's BPMN coverage, not the system. This stage inherits the stage-2 matching and therefore its threshold.

{% endif %} {% set ns = namespace(any=false) %} {% for bucket, label in [('task','Activities'),('event','Events'),('gateway','Gateways'),('lane','Lanes')] %} {% if aggregates.get('struct.subtype.confusion.' + bucket) %}{% set ns.any = true %}{% endif %} {% endfor %} {% if ns.any %}

Subtype confusion

{% for bucket, label in [('task','Activities'),('event','Events'),('gateway','Gateways'),('lane','Lanes')] %} {% set counts = aggregates.get('struct.subtype.confusion.' + bucket) or {} %} {% if counts %} {% set agreement = aggregates.get('struct.subtype.agreement.' + bucket) or {} %} {% set wrong = counts.items() | rejectattr('0', 'in', counts | select('equalto', none) | list) | list %} {% set off = [] %} {% for key, count in counts.items() %}{% if key.split('→')[0] != key.split('→')[1] %}{% set _ = off.append((key, count)) %}{% endif %}{% endfor %} {% if off %} {% for key, count in (off | sort(attribute='1', reverse=true))[:6] %} {% endfor %} {% else %} {% endif %} {% endif %} {% endfor %}
TypeAgreementSubstitution (reference → output)Count
{{ label if loop.first else '' }} {{ rate(agreement.get('rate')) if loop.first else '' }} {{ key.split('→')[0] }} → {{ key.split('→')[1] }} {{ count }}
{{ label }}{{ rate(agreement.get('rate')) }}none

Read reference → output: what gold had, then what the system produced. These are not interchangeable errors — an exclusive gateway modelled as a parallel one turns a decision into a fork; an inclusive modelled as exclusive narrows it. Sequence flows are excluded (they match structurally and always agree). Subtype is the XML tag, so a message event substituted for a timer is invisible here.

{% endif %} {% set band = aggregates.get('struct.sensitivity.thresholds') %} {% if band %}

Threshold sensitivity

{% for t in band %}{% endfor %} {% for bucket, label in [('task','Activities'),('event','Events'),('gateway','Gateways'),('flow','Sequence flows'),('lane','Lanes'),('all','All elements')] %} {% set curve = aggregates.get('struct.sensitivity.f1.' + bucket) or {} %} {% set ceil = aggregates.get('struct.sensitivity.ceiling.' + bucket) or {} %} {% for t in band %}{% endfor %} {% if ceil %} {% for t in band %}{% endfor %} {% endif %} {% endfor %}
Type{{ '%g'|format(t) }}Spread
{{ label }}{{ score(curve.get('%g'|format(t))) }}{{ '%.3f'|format(aggregates.get('struct.sensitivity.spread.' + bucket)) if aggregates.get('struct.sensitivity.spread.' + bucket) is not none else 'n/a' }}
↳ human ceiling{{ score(ceil.get('%g'|format(t))) }}

How much of a number is the threshold. Not an invitation to pick a better one — moving the threshold to improve a score is the failure this table exists to expose. A conclusion that holds across the whole band is a finding; one that appears only at the run's own threshold is an artefact of that number.

{% endif %}

Lint

Error-free outputs{{ rate((aggregates.get('gate.lint.error_free') or {}).get('rate')) }}
Clean (warnings included){{ rate((aggregates.get('gate.lint.clean') or {}).get('rate')) }}
Errors per output (median){{ (aggregates.get('gate.lint.errors') or {}).get('median') }}
{% set by_rule = aggregates.get('gate.lint.by_rule') or {} %} {% if by_rule %}

Findings by rule

{% for r, c in by_rule.items() %}{% endfor %}
{{ r }}{{ c }}

A headline count is routinely dominated by one rule that encodes a design decision in the system under test rather than a defect. Always read this table first.

{% endif %}

Normalisation

{{ aggregates.get('norm.rebuilt_flow_links', 0) }} <incoming>/<outgoing> links rebuilt across {{ aggregates.get('norm.outputs_needing_relink', 0) }} output(s) before any gate ran. Not a defect score — a signal about interoperability with tooling outside this harness.

Cases

{% for case in cases %}{% for rep in case.reps %} {% endfor %}{% endfor %}
CaseRepGatesOutput
{{ case.case_id }} {{ rep.rep }} {% for gid, g in rep.gates.items() %} {% if g.outcome == 'fail' %}{{ gid.replace('gate.','') }} {% elif g.outcome == 'skipped' %}{{ gid.replace('gate.','') }}: skipped {% endif %} {% endfor %} {% if rep.error %}no output{% endif %} {% if not rep.error %} raw · normalised {% else %}{{ rep.error }}{% endif %}
{% endif %} {% endblock %}