Every property platform ships a score. A number between 0 and 100 beside a project name, presented as judgement. Almost none can answer the only question that ever gets asked in the room: why this number, for this asset, today.
Not "what does the score mean." Not "how is it calculated, broadly." Why this asset scored 71 and the one next to it scored 68, and what moved since last month. A score that cannot answer that is not an analytical product. It is an opinion with a font.
This matters commercially before it matters technically. An investment committee does not reject a score because it is wrong. It rejects it because nobody in the room can take it apart. Below is the scoring model Recall built for the Reflexion property intelligence platform, in full: the formula, the four design decisions that are arguments rather than defaults, the interface contract, the time dependence, and three defects we have not fixed.
The formula, in the open
Score = 100 × (0.30·R + 0.25·P + 0.25·L + 0.20·A) × Q̃
R Yield 30% percent_rank of median unit yield, within the province
P Developer 25% 0.5·rank(completed projects)
+ 0.25·completion rate
+ 0.25·longevity (capped at 20 years)
L Zone liquidity 25% 0.5·rank(sales volume)
+ 0.3·rank(project count)
+ 0.2·freshness (under 30 days)
A Access 20% exp(−d/d₀)
Q̃ Quality × 0.8 + 0.2 × data_quality_score/100
Four weighted terms, one multiplier. Every term is a percent_rank or a bounded decay, which makes the composite robust to extreme values — a single outlier yield cannot drag a district.
| Term | Weight | Computation | Behaviour without data |
|---|---|---|---|
| R — Yield | 30 % | percent_rank of the median unit yield, computed within the province | 0.5. A yield ≤ 0 is treated as absent, not bad |
| P — Developer | 25 % | 0.5·rank(completed projects) + 0.25·completion rate + 0.25·longevity, capped at 20 years | Unknown developer → 0.3 |
| L — Zone liquidity | 25 % | 0.5·rank(sales volume) + 0.3·rank(project count) + 0.2·freshness (under 30 days) | See defect 1 below |
| A — Access | 20 % | exp(−d/d₀), d₀ = 800 m for rail in Bangkok, 1,500 m for beach in Phuket, Surat Thani, Chon Buri and Prachuap | No reference point → 0.5 |
| Q̃ — Quality modulator | multiplicative | 0.8 + 0.2 × data_quality_score/100 | Floor of 0.8, ceiling of 1.0 |
The view refreshes weekly, Sunday at 01:01, after developer_stats and project_distances, in that order. The ordering is not cosmetic: the developer term and the access term read tables that must already be current, or the score is computed against yesterday's geography.
Which of these choices are arguments, not defaults?
Four of them. Each one is a position we would have to defend, so each one is stated.
R is ranked inside the province, not globally. A 6 % yield in Bangkok is not a 6 % yield in Samui. Different cost bases, different vacancy structures, different buyer populations. Absolute thresholds — "above 5 % is good" — are exactly what makes a cross-market score meaningless: they quietly rank one market against another and call it an asset judgement. Ranking within the province makes the comparison honest and the geography load-bearing, which is why the province, district and sub-district hierarchy lives in the property ontology and knowledge graph rather than in a free-text field.
A yield of zero or below is absent, not bad. This is the single most common scoring bug in the industry, and it is almost always invisible. A project with no yield data and a project with a genuinely terrible yield are different objects. Collapsing them punishes the asset for a gap in the measurement. The correct behaviour is to fall back to the neutral 0.5 and let the quality modulator carry the penalty for thin data — which is precisely what it is there for.
An unknown developer scores 0.3, not 0.5. The neutral value would be 0.5. We do not use it. State the principle plainly: anonymity carries a cost. A developer with no verifiable completion history is not an average developer; it is an unpriced risk, and a scoring model that treats it as average is quietly subsidising opacity. 0.3 is a judgement, it is documented, and a client who disagrees can argue with the number instead of guessing at it.
Q̃ is multiplicative, and that is the whole point. A quality term added to the weighted sum would let clean data on one axis compensate for dirty data everywhere else — good hygiene on the developer record could manufacture a confident-looking score on an asset we barely know. Multiplied, it cannot. It scales the entire composite between 0.8 and 1.0, so dirty data can lower a score but can never invent one. Placement, not magnitude, is what makes it work: a data-quality term that can be outvoted is not a data-quality term.
Why store four sub-scores instead of one number?
Because the total is not the product. The decomposition is.
All four sub-scores are stored alongside the composite. That single storage decision is what allows an interface to justify a rating line by line: this project scores 71 because yield ranks in the 82nd percentile of its province, the developer term is 0.64, zone liquidity is 0.55, access decays to 0.41 at 1,100 m from the nearest station, and the quality modulator is 0.94. Anti-black-box by construction, not by policy.
The commercial consequence is the part that matters. A score you cannot decompose cannot be defended in front of a client, an investor or a regulator. When a developer calls to dispute their rating, "the model says so" ends the relationship. "Your completion rate term is 0.41 and here are the completed projects we have on record" starts a conversation — often one that ends with better data. Decomposition converts disputes into inputs.
The same logic runs through the provenance and confidence metadata attached to every record in the retrieval and provenance layer: a claim without its origin is not evidence, and a score without its terms is not analysis.
The interface contract, and the failure that raises no error
The sub-scores are stored on a 0–1 scale. The interface multiplies by 100 to draw the bars.
Pass them as 0–100 instead and nothing breaks loudly. No exception, no failed constraint, no alert. The bars clamp at their maximum and every project renders as excellent on every axis. The interface lies, silently, in perfect health. Rename the keys and the failure is identical in character: the bars render at zero and the page still returns 200.
This is the failure mode worth generalising. Loud failures are cheap — they page someone, they get fixed inside an hour. Silent failures are the expensive ones, because they degrade a product that continues to look correct, and they are discovered by a client rather than by monitoring. Any scoring pipeline crossing a storage-to-interface boundary needs the scale written into the contract and asserted at the boundary, not assumed by both sides. Two teams each holding a reasonable assumption about units is how a dashboard ends up confidently wrong.
Why did 627 projects move with no change to the formula?
Because the view depends on time.
The freshness component of the liquidity term compares each record's timestamp to now() − 30 days. The window slides. Run the identical code on two different dates and the scores differ, because the set of records qualifying as fresh has changed underneath it.
This is measured, not theorised. On 11 August 2026, comparing two executions two days apart:
| Measurement (2026-08-11) | Value |
|---|---|
| Executions compared | 2, two days apart |
| Changes to the formula between them | 0 |
| Projects with a changed liquidity sub-score | 627 |
| Projects with a changed overall score | 21 |
The gap between 627 and 21 is itself informative: the composite absorbs most of the movement, because a 25 %-weighted term has to move materially before it crosses a rounding boundary on the total. The model is more stable than its inputs. That is a design outcome of the weighting, not luck.
The operational rule follows directly, and it is the one to carry into your own governance: "the score moved" is never evidence that someone touched the calculation. A time-dependent view is a legitimate design choice — recency should influence liquidity — but it makes the question "did the model change?" unanswerable from memory. You answer it by comparing against a dated snapshot of the output, or you do not answer it at all. Every score we publish carries its measurement date for exactly this reason.
Three defects, published
Publishing your own defects, measured and bounded, is what separates a methodology from a marketing claim. All three of these are known, documented and currently uncorrected.
| # | Defect | Scope |
|---|---|---|
| 1 | The liquidity sub-score uses COALESCE(...,0) with no neutral value. A district missing from the statistics is indistinguishable from the least liquid district in the country — the same missing-versus-bad error the yield term explicitly avoids | Measured: 1 project out of 6,697 |
| 2 | The access term tests province = 'Bangkok'. Nonthaburi and Samut Prakan are therefore excluded, despite being served by the Purple, Green and Yellow line extensions. They fall through to the beach branch, then to the 0.5 default | Two provinces, metro-served |
| 3 | dist_rail_m (SRT and Airport Rail Link) is computed and stored, and is not used in the score | Deliberate, not an oversight |
Defect 1 is a real inconsistency, and its blast radius is one project. Stating both halves is the point: an unbounded defect is a risk, a bounded defect is a backlog item.
Defect 3 is not a bug at all, which is why it is listed here rather than quietly omitted. The walk value of a mainline or airport-rail station does not equal the walk value of a metro station — different frequencies, different trip purposes, different catchment behaviour. Folding them into one exponential decay would produce a number that looks more complete and means less. The distance is stored so that a future term can use it properly.
Developer scoring: stored axes, two populations
The same decomposition discipline governs developer ratings. The stored axes are score_track_record, score_quality, score_financial, score_bkk_segments, score_phuket_segments, score_chonburi_segments and score_brand, plus a meta-signal carrying confidence and density — a score's own statement of how much evidence sits behind it.
The population splits into 30 SET-listed developers and 1,547 unlisted. That asymmetry is the reason segment axes are stored separately rather than averaged: listed developers publish financials, unlisted ones do not, and a single blended "financial strength" number across both populations would be comparing two different kinds of evidence. The stored axes and the confidence meta-signal are what make the developer benchmarks readable as evidence rather than ranking.
Publication is automated daily at 05:00. The computation remains manual. That distinction is worth stating precisely, because it is routinely blurred: automating the publication of a result is not automating the production of it. The cadence you see is a distribution cadence.
What should you test against your own model?
Five tests. Each one takes under a day against a live scoring pipeline, and each one has a binary answer.
- Missing versus bad. Insert a record with a null yield and one with a catastrophic yield. If they score identically, your model is punishing assets for gaps in your own data collection.
- Neutral defaults, chosen out loud. For every fallback in the model, find the line that sets it. If a default is 0 because
COALESCEmade it 0, that is not a decision — it is a side effect with an opinion. - Quality placement. Check whether the data-quality term is added or multiplied. If it is added, construct the case where a clean record on one axis produces a confident score on an asset you barely know. It exists.
- Dated snapshots. Re-run the unchanged model on two dates a week apart and diff the output. If you cannot produce that diff, you cannot ever prove the formula was untouched — you can only assert it.
- Decomposability under challenge. Take your highest-scoring asset and your lowest, and write the one-paragraph explanation you would give a client for each. If you need to read the source code to write it, your sub-scores are not stored.
A score that survives those five is defensible in a room full of people paid to challenge it. That is the only test of a scoring model that has ever mattered.
Recall builds and operates this layer for operators who ship it under their own name. The full architecture — truth hierarchy, ontology, provenance, refresh contracts — is documented in the methodology.