Skip to the case

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
Grid Memory is the Corsi Block Test rebuilt as an adaptive game. The round drawn here is a fixed schematic generated at build time — not a capture, not session data, not a real player. LQ is used by children and no real session appears anywhere on this page.
Scene 02 — Problem

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.

Scene 03 — Approach

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
Span 5 — clustered · index 1.35
Span 5 — dispersed · index 2.92
Same span, same accuracy, different structure. The number under each board is the mean distance between every pair of recalled cells — 10 pairs on each side, computed at build time from the two fixed layouts drawn above, and 2.16× apart (arithmetic, 2.92 ÷ 1.35). This index is a schematic stand-in written for this page to make the idea visible; it is not the platform’s SCS formula, and no session data was used to produce it. What it stands in for is the real point: a clinical span score assigns both of these boards the same value, and a spatial clustering measure does not.

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.

Scene 04 — Obstacle

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.

The lifecycle, schematically. Two workers ask for work in the same instant and both are pointed at the oldest pending row. One of them gets it. The other is not put in a queue behind the first and is not made to retry — it is simply handed the next row, in the same statement, with no round trip. That is what 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.

Scene 05 — Result

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.

  1. 01EnqueuePOST /coaching/analyzependingThe API writes one row and answers immediately. Nothing waits on a model.
  2. 02ClaimPOST /jobs/nextprocessingThe worker asks for work. One row is claimed atomically, or the worker is handed the next one.
  3. 03Runpython · ollama · qwenprocessingInference happens on a machine that has a GPU. The server never needed one.
  4. 04CompletePOST /jobs/:id/completedoneThe result is written back onto the same row. On failure the error lands beside it and the status reads failed.
The path of one coaching request. The API answers before any inference has happened; the row is the promise. Every state transition is a write to a single row, which means the whole state of the system is one 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 hidden

No 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.

The parts of LQ, where each runs, and what each is responsible for
PartRunsResponsible for
Flutter + Flame mobileAndroid · offline-firstThe games, and the record of what happened in each round
NestJS APIServer, no GPUEndpoints, the job table, retrieval for the coach
Next.js adminServerThe operator surface
PostgreSQLServerUsers — and the job queue itself
MongoDBServerGame-session data
Python workerA machine that has a GPUPolls over HTTP, runs Qwen through Ollama
pgvector · knowledge_chunksInside PostgreSQLRetrieval that feeds the coaching prompt
Message broker—Not present. The queue is a table in PostgreSQL.
Scene 06 — Close

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