The calculation, briefly
Each apprenticeship was divided into periods. The exact number depended on the course (twelve for one apprenticeship, twenty-one for another). Each period had an expected completion benchmark. A learner's progress was assessed against the benchmark for the current period, taking into account off-the-job hours logged, e-portfolio assessments completed, course component completion and any approved breaks that adjusted the period clock. The output was a RAG status (red, amber, green) plus the underlying numerical breakdown that fed the tutor and supervisor views.
The calculation was not arithmetically deep. It was operationally deep: there were many inputs, several edge cases, and the domain expert was actively refining the rules as the engagement progressed.
Three layers, one engine
The progress engine had three layers with explicit boundaries:
- Ingestion. Adapters per source. Campus was queried in real time. OneFile arrived as overnight CSV exports (originally via an upstream Azure Blob pipeline, later directly from SFTP after the pivot covered in a separate case study). Benchmark CSVs were ingested from SharePoint. ITP data came in as per-learner spreadsheets. The output of this layer was a consolidated learner record with freshness metadata attached to each field.
- Calculation. Pure functions over the consolidated record. No awareness of source systems, no I/O, no freshness model leaking in. The output was the RAG state and the underlying breakdown.
- Persistence and exposure. The calculated results stored against the learner identity, exposed to the BFF with freshness propagated through, and made available to any future consumer.
The calculation layer was deliberately the thinnest of the three. That kept the boundary obvious and made the sign-off mechanism trivial to implement.
Sign-off mechanism
For any change touching the calculation layer, I generated a comparison: a reference set of learners, the Power BI output for those learners, and the engine output for the same learners. The domain expert reviewed the side-by-side, signed off on differences (zero, or "expected, here is why"), and the change was merged. That made Power BI the authority during the build, with the engine catching up on a deliberate cadence rather than fighting Power BI for ownership of the calculations.
A more conventional approach would have been to write a specification document and have the domain expert review it. That works on paper. In practice, asking a domain expert who has spent months building calculations in Power BI to verify a textual specification of the same logic is asking them to do their work twice in two media. Comparing one set of Power BI numbers to another set of engine numbers is the same task they were doing already, only with the engine added.
Why Power BI stayed alive
The temptation in any "consolidation" engagement is to point every consumer at the new service and retire the old tool. I argued against that here. Power BI was the working environment for the domain expert, and the place internal management information was already being produced. Forcing it to become a downstream consumer of the engine would have made every Power BI report dependent on the engine's availability, and would have forced calculation iteration through a code-deploy cycle rather than a Power BI refresh.
Keeping Power BI alongside the engine, with both consuming source data, kept the domain expert's iteration cycle intact and gave the engine a permanent reference point to validate against. The architectural cost was low. The operational benefit was high.
What I would do differently
I would invest earlier in an explicit, machine-readable representation of the period and benchmark configuration, separately from either Power BI or the engine. We ended up with the configuration encoded twice, once in Power BI and once in the engine, with the sign-off mechanism keeping them aligned. That worked, but it is a cost I would prefer to remove. A single source of truth for period definitions and benchmarks, consumed by both Power BI and the engine, would have been a cleaner answer. I did not push for it on this engagement because the deadline was elsewhere; on a future engagement of this shape I would.