Technical document

Tactiq

AI football match-analysis platform. Designed, built and operated solo.

Overview

Pre-match and live tactical analysis, outcome probabilities and supporting statistics for individual fixtures, aimed at fans who want reasoning rather than a single number. One Flutter codebase across iOS, iPadOS, Android and macOS. 32 languages. Subscriptions priced across 175 countries using World Bank purchasing-power data. A free tier plus three paid tiers, settled server-side. Most engineering effort sits in the prediction and language layers rather than the interface, because the analysis is the product.

Prediction engine

A layered pipeline rather than a single model

Strength, scoreline distribution, recent form, qualification scenarios and match context are each answered by their own component rather than by one model that does everything.

  • Separately measurable. The point of splitting them is not accuracy for its own sake. A wrong answer has to be traceable to the part that produced it, and that part has to be changeable without moving the others.
  • Two estimators, compared rather than blended. A statistical engine and the language model each produce an implied view of the same fixture, and the system compares them instead of averaging them. Every fixture carries the verdict of that comparison, because disagreement is information: agreement raises trust and disagreement earns caution, where a blend would have quietly manufactured false precision out of two answers that did not agree.
  • Bounded outputs. Every probability the system produces is bounded before it leaves, so that one extreme input cannot produce a distribution no reader would believe. Sanity is enforced at the boundary rather than hoped for from the model.
  • Directional priors. Recent form enters as a direction of travel rather than an absolute level, because a mid-table team improving and a strong team declining are different bets that an average hides.
Machine learning layer

Built in-house, in pure Python

Scikit-learn, gradient boosting and deep-learning runtimes were deliberately not adopted. Serverless functions need small memory footprints and fast cold starts, the models must stay interpretable so a wrong result traces to a cause, and at this data scale a transparent model correctable per league beats an opaque one that is hard to debug when a single league drifts.

  • Online rating updates. Team ratings update from every graded result with no offline retraining step, so the strength model moves with recent reality rather than a static seed.
  • Calibration feedback loop. Every past prediction is graded against its actual outcome, scored with a Brier metric and a reliability breakdown by confidence band, and fed back into both probability shaping and the drift thresholds that raise an alarm.
  • Clustering for thin history. Where two teams have played each other too rarely to say anything, similar teams inform the prior instead. Written by hand rather than pulled from a library, for the same footprint and interpretability reasons as everything else in this layer.

The learning stays legible. Every number a user sees traces to a rating, a calibrated probability or a cluster, each of which can be inspected and corrected.

Language model layer and guardrails

Two model tiers, and nothing trusted on the way out

Amazon Bedrock with two model tiers: a cheaper model via cross-region inference profiles for the free tier, a stronger model on the global profile for paid tiers. Output tokens dominate cost, so the stronger model is reserved for users whose spend justifies it.

  • Server-authoritative grounding. Team, player and tournament names and statistics are never read from model output. They are injected from verified data after generation and the model is instructed to treat them as fixed. The model structurally biases toward famous clubs and invents plausible but wrong entities, so identity is treated as data rather than as something the model may decide.
  • Confidence floor. Generated probabilities are bounded so the model cannot express certainty the data does not support. An overconfident wrong call damages trust more than a hedged one.
  • Structured output contracts. A fixed JSON schema, validated before use. A result failing validation or post-checks is never written to cache, because a bad cached result is served to every later reader of that key, turning one error into many.
  • Language guard. Generated prose is classified against the requested language, using script dominance for non-Latin scripts and stopword and diacritic scoring for Latin ones. On mismatch it regenerates once at temperature zero with a correction directive, and if it still fails it is not cached. For a 32-language product, answering in the wrong language is a correctness failure rather than a cosmetic one.
  • Fallback and decline. A status gate blocks analysis on finished match states and during penalty shootouts. Until the provider reports a fixture finished, any minute of it can be analysed; there is no cut-off before the whistle. Guard failures fail open on the user path but fail closed on security and quota: a guard error should never silently deny a paying user, and an auth error must never grant free access.
Request App attestation HMAC request signature Signed short-lived token fail-closed fail-closed fail-closed Generation Grounding check Schema validation Language guard server-authoritative shape enforced requested locale only Cache decision Response Discarded rejected, never repaired flow control and authority rejected HMAC, JWT and batch secrets held per region language mismatch raises a named alarm
Request App attestationfail-closed HMAC signaturefail-closed Signed tokenfail-closed Generation Grounding checkserver-authoritative Schema validationshape enforced Language guardrequested locale Cache decision Response Discarded rejected, never repaired flow control and authority rejected HMAC, JWT and batch secrets held per region language mismatch raises a named alarm
Monthly ceilingset before scaling 300 USD Bedrock inference 500 USD everything else Tier decision entitlement, checked server-side Cheaper modelfree tier Stronger modelpaid tiers Output checks grounding, schema, language Cache decision a failing result is never cached Response output tokens dominate cost, so the stronger model is rationed the ceiling came first; the two tiers follow from it a daily report on token spend
Monthly ceilingset before scaling Tier decision Cheaper modelfree tier Stronger modelpaid tiers Output checks Cache decision Response one tier or the other, never both entitlement checked server-side a failing result is never cached the ceiling came first, tiers follow
Calibration and monitoring

The system grades its own past work

A weekly monitor, scheduled Sunday at 22:00 UTC, joins graded fixtures back to their predictions and reports hit rate for the rating engine against the language model, broken down per league, per tier and per confidence band, alongside calibration error, latency and null rate. Results are written to dedicated calibration record and summary tables rather than logs, so a historical read is always available. The language model runs as a live baseline rather than an absolute target, because beating a competent baseline is the honest test of whether the extra machinery earns its place.

Drift detection uses calibration error and per-league accuracy, with minimum sample sizes attached to every alarm so a thin window cannot trigger one. Hit-rate floors are defined for the overall and free-tier paths; when one is breached the system raises an alarm and the rollback is performed from a runbook. Detection is automated, the response is not, and that distinction is documented rather than blurred.

An automated response is designed and staged rather than live. Its first stage deliberately holds no write authority, because a system that can revert itself on a signal it may have measured badly is a system that can take itself down on a bad week.

A worked example. A calibration alarm fired on a thin off-season sample of around eighty graded events. I chose not to refit, because the high-confidence bucket held only ten events. Two weeks later, on roughly four hundred events, calibration measured as good and the restraint was confirmed correct.

Verify sample size before acting on an alarm. A small sample produces alarms that look like model failure and are not.

Predictions issued Real outcomes match ends, number is fixed Graded Hit rate perconfidence band Calibration errorvs live baseline not an absolute target Drift alarm threshold breach Manual rollback weekly cycle weekly refit Sunday 22:00 UTC · incremental tracking every six hours · counterfactual replay Mondays thin samples are held, not fitted the alarm is automatic, the rollback is not
Predictions issued Real outcomesmatch ends Graded Hit rate per confidence band Calibration error vs baseline Drift alarmthreshold Manual rollback ↺ weekly cycle weekly refit Sunday 22:00 UTC incremental tracking every six hours thin samples are held, not fitted alarm automatic, rollback by hand
Data layer

Enriched where it is worth it

The competition catalogue runs to four figures, and a featured subset receives deeper enrichment. Full-depth enrichment everywhere would exceed provider quotas on leagues almost nobody opens, so depth is spent where it is read rather than spread evenly to look thorough.

Normalisation runs at both write and read time. Group-stage standings, for instance, are de-duplicated by team because the provider doubles rows once results arrive, and the read path cleans stale shapes so a fix takes effect without waiting for a re-sync. Freshness is matched to volatility rather than set to one interval: what changes by the minute is polled by the minute, and what changes twice a season is not.

Missing fields resolve to safe defaults or to an empty state the interface renders honestly, and provider schema changes are absorbed by defensive normalisation, because a silently changed field upstream should degrade to a blank and never to a fabricated value.

Platform architecture

Three regions, active-active, and one deliberate exception

One hundred and seventy-six Lambda functions in production across three regions, behind latency-based routing with sixteen Route 53 health checks. Forty-three distinct functions are replicated to all three in an active-active layout; the remainder are deliberately single-region scheduled and backfill jobs, because running a cron in three regions produces three writers competing over the same state. The distribution is deliberate rather than symmetric: us-east-1 carries eighty and runs the batch, calibration and tournament workloads, while eu-central-1 and ap-northeast-1 carry forty-four and fifty-two and serve the user path. On the analysis path this means two regions can fail and the third still serves. Runtime is split roughly evenly between Python 3.12 and Node.js 24. The function estate is high by design: prediction, language, calibration and data layers are separate functions so each deploys, tests and rolls back independently, which matters more than minimising surface when correctness sits on the revenue path.

State lives across forty-one DynamoDB tables. Thirty-six replicate to all three regions as Global Tables. Five are held outside global replication deliberately: paid entitlement and refresh tokens sit in a single-region authority with one writer using atomic conditional writes keyed on the last event timestamp, then propagated outward to the multi-region user tables. Propagation and reconciliation are two different jobs and worth naming separately: propagation is event-driven and settles in minutes, while reconciliation is a weekly pass comparing the ledger against store exports.

Entitlement does not inherit the resilience of the analysis path, and the trade is deliberate. The authority table has no replica. If its region is lost, existing paid access carries on being served from the multi-region user tables that propagation has already written, while new tier changes are not accepted until the region returns. Nobody is silently granted access they did not buy and nobody loses access they did, which is the direction this is supposed to fail in.

Paid entitlement is derived state rather than a system of record. The upstream truth about who has paid is held by the stores, not by this table, so losing the region is a rebuild rather than a failover: the reconciliation job that runs against store exports can construct the table from nothing. That the weekly check runs against the stores rather than against our own history is the practical consequence of the same fact. If the store is the source of truth, drift has to be measured against it. The mechanism exists. A measured recovery time does not, and this is not claimed as a disaster recovery plan.

Every request passes three fail-closed checks before any logic runs: app attestation, with Play Integrity on Android and App Attest on Apple, an HMAC signature over the request, and a signed token. Infrastructure is Terraform, with CI running syntax, tests and localisation completeness gates on every push.

Clients iOS · Android · web · macOS API Gateway latency-based routing health-check failover us-east-1 80 functions primary · batch, calibration, tournament eu-central-1 44 functions user path ap-northeast-1 52 functions user path Single-region authority entitlements · refresh tokens one writer atomic conditional write propagated every minute active-active · 176 functions · 41 DynamoDB tables 16 Route 53 health checks 36 tables replicate globally, five are held outside replication deliberately
Clients iOS · Android · web · macOS API Gateway latency-based routing health-check failover us-east-1 80 functions primary · batch, calibration, tournament eu-central-1 44 functions user path ap-northeast-1 52 functions user path Single-region authority entitlements · refresh tokens one writer · atomic conditional write propagated every minute active-active · 176 functions 41 DynamoDB tables · 16 Route 53 checks 36 tables replicate globally, five are held outside replication

Multi-region last-writer-wins can silently drop a tier change, and a customer losing paid access is the failure the whole design exists to avoid.

Monetisation and entitlement

Settled server-side, never trusted from the device

Four subscription products plus promotional grants. Usage is metered as a per-tier daily allowance rather than a purchasable balance, decremented by atomic conditional write and only on successful generation, because charging for a failed request is both wrong and a support cost.

A store event reaches a webhook, updates the single-region authority, and propagates to the multi-region user tables, so the client displays state it cannot itself grant. Purchases span the Apple ecosystem, where iOS and iPad share a purchase and macOS is a separate Mac App Store product, and Google Play, merged into one cross-platform entitlement. Plan changes follow store semantics, upgrades immediate and downgrades deferred to period end, with refunds and chargebacks on the same event path and the weekly reconciliation against store exports catching drift.

Prices are set per country from purchasing-power data rather than by flat currency conversion, because a flat price is unaffordable in some markets and undersells in others.

Store event Apple or Google, a purchase, a renewal, a refund Webhook never trusted from the device Single-region authority one writer, atomic conditional write Propagation event-driven, minutes not days Multi-region user tables read everywhere, written in one place Client displays it state it cannot grant itself a device that asks for more than it paid for is answered by the table, not by itself multi-region last-writer-wins can silently drop a tier change
Store event Webhook Single-region authority Propagation Multi-region user tables Client displays it entitlement is never trusted from the device one writer, atomic conditional write the client displays what it cannot grant
Cost control

The ceiling was set before the scaling

Two hard budget caps sit on the account with alarms attached: three hundred dollars a month for Bedrock inference and five hundred for everything else. They are not forecasts. Setting the ceiling first forced the two-tier model routing, the caching rules and the decision about which requests justify a stronger model.

A generative product without a ceiling discovers its unit economics in a billing email.

Localisation

32 languages, no machine translation in delivered strings

A football audience notices wrong terminology immediately, and a mistranslated tactical term reads as incompetence. Three surfaces are managed separately: in-app strings with a CI completeness gate so no language ships partial, store listings, and legal texts. The runtime language guard closes the loop, since native static strings do not help if the model answers in English.

Strings written no machine translation in delivered strings CI completeness gate a partial language does not ship Released app, store listing, legal text Runtime language guard the model answers in the language that was asked for Reader static strings do not help if the generated prose arrives in English wrong language is a correctness failure, not a cosmetic one
Strings written CI completeness gate Released Runtime language guard Reader no machine translation in delivered strings a partial language does not ship wrong language is a correctness failure
Release and rollback

Built once, shipped everywhere, reverted by hand

Infrastructure is Terraform and every push runs the gates before anything moves: syntax, tests, and a localisation completeness check that refuses to ship a language with holes in it. The artifact is built once and the same bytes go to all three regions, because a region running slightly different code is a failure that only shows up as an inconsistency nobody can reproduce.

Parity is checked after the deploy rather than assumed, and a drift pass compares what is actually running against what is declared. Rollback is a runbook step. A breached hit-rate floor is what raises the alarm; a person reads it and decides, and the reversion is a documented procedure rather than something the system performs on its own.

Push every branch, every commit CI gates syntax, tests, localisation completeness Artifact built once the same bytes reach every region Three regions parity checked after, not assumed Drift check what is deployed against what is declared Serving rollback a hit-rate floor raises the alarm; the revert is run from a runbook built once and shipped everywhere, so no region runs different bytes
Push CI gates Artifact built once Three regions Drift check Serving syntax, tests, localisation completeness built once, the same bytes in every region a hit-rate floor alarms, a person reverts
Privacy and data rights

Ahead of the business logic, not bolted on after

Every request passes attestation, request signing and authorisation before it reaches anything that does work, and each of those fails closed. Cloud permissions are least privilege by default rather than by review. The data model stores what a feature needs and not what might one day be useful.

Export and erasure are product features rather than support tickets, designed in from the first release. Retrofitting either one into a system that was not expecting them is how a small compliance question becomes a quarter of engineering time.

Request nothing reaches business logic first Attestation and signing the caller is the app, and the request is intact Authorisation fail closed, least privilege Stored data only what the feature needs Export Erasure both are the reader's to ask for, and neither is a support ticket designed in from the first release rather than retrofitted
Request Attestation and signing Authorisation Stored data Export Erasure nothing reaches business logic first fail closed, least privilege export and erasure, not support tickets
Featured work: World Cup 2026 hub

An isolated fleet, removable without touching the core

A high-traffic seasonal event needed to be built, tuned and later removed without touching the main system, so it was built as a separate fleet. It carried its own handling for the ways a tournament differs from a league season, and a controlled live-analysis setting where many users repeatedly analysed the same fixtures, which produced unusually dense calibration data that fed back into the main engine.

The measurable outcome was a per-league accuracy and calibration record from real tournament traffic, and a set of improvements validated there before being considered for the wider league season.

Contact

Written by Murat Can Ümit, who designed, built and operates all of the above. The rest of the work, and a contact form, are on the main site.

Back to the main site