Scene 01 — Case · Cognitive telemetry platform · by Vera
LQ
Learning Quotient — measuring cognition, then coaching against the measurement
A platform that measures cognitive performance through neuroscience-grounded games, reads each session back as telemetry, and hands an AI coach something specific enough to train against.
The clinical instrument underneath is old and well understood. What LQ adds is instrumentation: instead of the single number a memory test hands back, the session records how the pattern was held — grouped or scattered, evenly timed or stalling — and the difficulty moves with the player rather than climbing a fixed staircase.
The part of this project worth a technical reader’s time is further down, and it started with a constraint: the server it had to run on has no GPU.
- Flutter
- Flame
- NestJS
- Next.js
- PostgreSQL
- MongoDB
- pgvector
- Ollama
- Kind
- Product, built under VeraHQ
- Live
- lq.verahq.com.tr
- Build
- Android APK v1.0.4.1
- Shape
- Monorepo — Flutter mobile · NestJS API · Next.js admin
- Data
- PostgreSQL — users · MongoDB — game sessions
- Span
- 5 positions presented
- Correct
- 4 of 5, in order
- Error
- Step 5 landed 1 cell away — an adjacent slip, not a random guess
A span is one number, and it arrives at the end
The Corsi Block Test has been in clinical use since 1972. An examiner taps a sequence of blocks, the subject repeats it, and the sequence grows until it breaks. What comes out is a span — an integer. That integer is real, and it is also the end of the measurement rather than the middle of it.
Two children with the same span can be doing completely different things. One holds the pattern as two grouped chunks; the other holds five independent positions. One answers in an even rhythm; the other stalls before every third tap. One misses by landing on a neighbouring cell; the other misses by guessing. A span cannot tell them apart — and the difference is exactly what anyone designing the next exercise would want to act on.
Fixed difficulty is the second cost. A test built to find a ceiling spends most of its time either too easy to be informative or too hard to be bearable, and it spends none of it where learning actually happens.
So the problem was not to score the test better. It was to record the round instead of the result — and then to keep recording it while the definition of what is worth recording is still moving.
Ground
stated foundations- Corsi Block Test · 1972
- A spatial working-memory assessment in clinical use since 1972. LQ turns it into an adaptive game rather than a fixed staircase.
- N-Back paradigm
- A working-memory updating task that, on the platform’s own account, activates the dorsolateral prefrontal cortex.
- Spatial Clustering Score
- Stated to reflect hippocampal spatial organisation — it shows how a child groups patterns, not how many they held.
These are the platform’s own stated foundations, reported here as it states them. Nothing on this page claims an independent validation of them.
Instrument the round, not the result
The game is written in Flame inside an offline-first Flutter app, so a session completes on a bus with no signal and syncs later. What it stores is the round: every tap, in order, with what the difficulty engine was doing at the time.
More than twelve cognitive metrics come out of that record. Six of them are named on this page — SCS, TCS, IRT, accuracy, chunking efficiency and cognitive load — and that is six of a figure the platform states as twelve or more, not the whole set. The adaptive engine reads cognitive load back in real time and moves the difficulty against it, which is what keeps the player near the edge of their ability instead of walking a staircase built for someone else.
The figure below is the cheapest way to show why one number was not enough. Both boards carry the same span and the same accuracy. They are not the same result.
Measured
6 of 12+ named- SCS
- Spatial Clustering Score — How the recalled positions group in space
- TCS
- Carried here under the abbreviation the platform uses
- IRT
- Carried here under the abbreviation the platform uses
- Accuracy
- How much of the sequence came back, in order
- Chunking efficiency
- How far the pattern was compressed before it was held
- Cognitive load
- The signal the adaptive engine optimises against
JSONB where the shape is still moving
In game_sessions, three columns are JSONB on purpose: roundDetails, adaptiveState, cognitiveMetrics. Those are the three shapes that moved every time something new turned out to be worth recording. Giving each of them a proper column would mean a schema migration per idea, and a migration is a bad price to pay for a question you are still asking.
The parts of the row that are not moving — who played, when, which game, which build — stay relational, indexed and constrained like any other data. The distinction is not "structured versus unstructured"; it is "settled versus still being learned", and only the second half is allowed to be a document.
The same split shows up one level higher: PostgreSQL holds users, MongoDB holds game-session data. Two stores, two jobs, chosen for the shape of what they carry rather than for a preference about databases.
The three documents
jsonb, deliberate- roundDetails
- What happened inside the round, tap by tap
- adaptiveState
- Where the difficulty engine had got to
- cognitiveMetrics
- The metrics derived from that session
Three columns — and three schema migrations that never had to be written each time the metric set changed shape.
The server has no GPU
The coach is a language model. The machine the platform runs on has no GPU, and it was not going to get one. That is not a bug waiting for a fix; it is the condition the design has to hold.
Two ways out were available and neither was taken. The first is to rent a GPU, which turns a fixed constraint into a recurring cost the product would carry forever. The second is the reflex answer: push inference off the box behind a queue, and bring in a message broker to hold that queue — Redis or RabbitMQ, plus its client library, plus its own durability and retry story, plus one more service that has to be running at three in the morning for the product to work.
What was already on the box, and could not be removed without the product ceasing to exist, was PostgreSQL. And a job queue is not much more than a table you can take rows out of without two readers taking the same one. Postgres has had that primitive for years. It is called SKIP LOCKED.
Turning point
The queue did not need a new component. It needed a table, an index and a lock clause in the database that was already there.
ai_jobsstatus
- job 01coaching.analyzependingprocessingdoneW1W2skip locked
- job 02coaching.analyzependingprocessingdone
- job 03session.summarypending
- job 04coaching.analyzepending
partial index — (created_at ASC) where status = 'pending'
FOR UPDATE SKIP LOCKED buys, and it is the whole reason no broker was needed. Generated at build time; not a capture, and no throughput or queue depth is being claimed here.The claim, and why the index is partial
-- One statement. Atomic, and it never waits on another worker.
UPDATE ai_jobs
SET status = 'processing', claimed_at = now()
WHERE id = (
SELECT id
FROM ai_jobs
WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING id, type, payload;
-- The index carries only the rows that are still waiting.
CREATE INDEX ai_jobs_pending
ON ai_jobs (created_at ASC)
WHERE status = 'pending';The shape above is the pattern, written out for reading rather than quoted from the repository. The two parts that matter are both in it. SKIP LOCKED is what makes two workers safe without a coordinator: the second reader does not block on the first one’s row lock, it steps over it. And the index is partial — it indexes only rows whose status is still pending. A finished job leaves the index the moment its status changes, so the structure the claim walks stays roughly the size of the backlog while the table itself grows without limit.
The worker on the other end is a local Python process. It polls over HTTP, takes whatever it is handed, and runs Qwen through Ollama on hardware that does have a GPU. Nothing about that machine is special to the platform: it can disappear mid-job and the row is still sitting in ai_jobs, claimed and unfinished, which is a state a human or a sweep can see and act on. A broker would have hidden that state inside itself.
ai_jobs
9 columns- iduuidJob identity
- typetextWhich kind of work this is
- payloadjsonbEverything the worker needs
- statustextpending · processing · done · failed
- resultjsonbWritten back on success
- errortextWritten back on failure
- claimed_attimestamptzStamped by the claim
- completed_attimestamptzStamped on completion
- created_attimestamptzThe queue order
9 columns — counted from the list above. Status moves through 4 values: pending · processing · done · failed.
What runs, and what was not measured
The counts below describe the shape of the system. They are structural facts, not performance figures, and the difference is stated rather than blurred.
3
Apps in the monorepo — Flutter mobile, NestJS API, Next.js admin. Arithmetic: the three listed.
2
Databases, one job each: PostgreSQL for users, MongoDB for game-session data.
1
Table carrying the entire AI job queue — ai_jobs, plus one partial index.
0
Message brokers. The queue is the database the product could not run without anyway.
- 01Enqueue
POST /coaching/analyzependingThe API writes one row and answers immediately. Nothing waits on a model. - 02Claim
POST /jobs/nextprocessingThe worker asks for work. One row is claimed atomically, or the worker is handed the next one. - 03Run
python · ollama · qwenprocessingInference happens on a machine that has a GPU. The server never needed one. - 04Complete
POST /jobs/:id/completedoneThe result is written back onto the same row. On failure the error lands beside it and the status reads failed.
SELECT away at any moment. Schematic of the flow, generated at build time.Retrieval, and not believing the model about itself
The coaching prompt is not just the session. A knowledge_chunks table with pgvector supplies retrieval, and rule-based domain interpretations are injected alongside it, so the model is reasoning over the platform’s own reading of the metrics rather than inventing one.
The piece I would defend hardest is what happens to the answer. The model’s self-reported confidence is penalised against objective system state. A model that says it is certain while the data underneath does not support that certainty gets marked down, not believed. Self-reported confidence is the cheapest thing a language model produces and the easiest thing for a product to mistake for evidence.
Not measured
stated, not hiddenNo throughput figure, queue depth, end-to-end latency, model quality score or user outcome appears anywhere on this page. None of them was measured, and a number invented to fill that gap would be worth less than the gap.
Unmeasured, and said so.
What is claimed here is architectural: which parts exist, what each one is responsible for, and why one of them is absent.
| Part | Runs | Responsible for |
|---|---|---|
| Flutter + Flame mobile | Android · offline-first | The games, and the record of what happened in each round |
| NestJS API | Server, no GPU | Endpoints, the job table, retrieval for the coach |
| Next.js admin | Server | The operator surface |
| PostgreSQL | Server | Users — and the job queue itself |
| MongoDB | Server | Game-session data |
| Python worker | A machine that has a GPU | Polls over HTTP, runs Qwen through Ollama |
| pgvector · knowledge_chunks | Inside PostgreSQL | Retrieval that feeds the coaching prompt |
| Message broker | — | Not present. The queue is a table in PostgreSQL. |
What this case is evidence of
The constraint did not need a new component. It needed a closer look at the one already running.
The instinct when a machine says no is to add something: a broker, a worker fleet, a second runtime, a managed service. Every one of those is also a thing to configure, monitor, upgrade and be woken up by. The answer here was to notice that durability, ordering and an atomic claim — the three things a job queue actually needs — were already sitting in the database the product could not run without. One table, one partial index, one lock clause.
The same instinct runs through the rest of the system. Do not migrate a schema for a shape that is still changing, so three columns stay JSONB. Do not take a model’s word for its own certainty, so confidence is penalised against system state. Do not report a number nobody measured, so this page reports none.
None of that is exotic. It is the ordinary discipline of asking what a system already has before asking what to add to it — and on a machine that was never going to be upgraded, that question turned out to be the whole design.
Techniques
applied- PostgreSQL job queue
- SKIP LOCKED
- Partial indexes
- JSONB schema design
- pgvector retrieval
- Flutter · Flame
- NestJS
- Offline-first sync
- Role
- Freelance developer, VeraHQ
- Build
- Android APK v1.0.4.1