Jump to content
Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

Since 7 October 2026 every request to landrace.wiki passes through Cloudflare, a security service brought in after a denial-of-service attack made the site unreachable. The Privacy policy now explains what this means for your data.

This is a documentation subpage for Grow-out priority. It contains usage information, categories, and other content that is not part of the original template.

This module works out the readings of the grow-out priority method, version 0.1: the seed lot grade, the holdings measure of a population and the ranks used to order populations for grow-out and collecting. The field grade itself is worked out by Module:Population on each population page; this module reads the stored grade. The rules are the method's own. The same code reads every holder's lots, and every reading shows the date it was worked out and the method version.

The module reads stored properties through #ask, works out each reading when the page is displayed, and can store the result with #set so that pages higher up can count readings. A reading that depends on the date, such as a test falling overdue, changes without an edit, so each one shows the date it was worked out ("as of").

Readings

Reading Values
Seed lot Urgent · At risk · Secure · Data deficient
Holdings Urgent · At risk · Secure · Data deficient
Field grade (read from the population page) Very high · High · Moderate · Low · Very low · Data deficient

Every reading is shown with its own label ("Seed lot: Data deficient", "Field grade: Data deficient"), never a bare word.

Functions

All functions are called with {{#invoke:Grow-out priority|function|…}}. Parameters not given on the call are read from the calling template.

Function Parameters Returns
lot lot (the lot's page or subobject; default the current page), asof (YYYY-MM-DD; default today), set=yes The lot's reading: its step, the factors behind it, the next actions and the next test date. With set=yes it also stores the values listed under Stored values on the current page.
population population (default the current page), asof, set=yes The holdings reading, with copies and collections counted, the places reading and any collection whose lots are all Urgent.
countTable sizes (default 25,50,100,200), firsts (first-test percentages, default 90,95) A table of the seedlings needed to pass, and the most that is clearly below, for each test size and line.
version none The method version, v0.1.

Lua callers can use the engine directly: evaluateLot(id, data, ctx), evaluatePopulation(id, data, ctx), readHoldings(entries), lapse(grade, asof), walk(accession, data), passesAt(n, p), clearlyBelowAt(n, p) and ranks(holdings, band, bestLot). data holds three tables keyed by page name: lots, accessions and populations; ctx.today fixes the date.

The seed lot grade

A lot is judged on eight factors, each by name: germination, seeds left, storage, backup copy, replaceability, mother plants, handed out and grow-out history. Each factor passes, reaches a warning, crosses a hard line or is not known. Nothing is added or weighted:

  • any hard line: Urgent
  • otherwise any warning: At risk, or Urgent for an irreplaceable lot
  • every factor known and passing: Secure
  • no known problem but gaps: Data deficient, with the missing factors listed

Among the reasons for Urgent, germination is listed before seeds left. An empty field always means not known and never counts for safety.

Factor Hard line Warning Not known
Germination latest test clearly below 70%, or below 85% of the first test (exact binomial, one-sided, 95%) between the lines; or a percentage with no count, below a line never tested; a percentage with no count at or above the lines
Seeds left fewer than three sowings (3 × 150 ÷ germination, rounded up); under 450 when germination is not known under 3,000; 450 to 2,999 when germination is not known no count; 3,000 or more with germination not known
Storage room temperature with moisture over 5% or not known a test overdue; never tested once the first test was due storage not reported
Backup copy the holder states there is no backup backup under 500 seeds expected to germinate, or at the same site or in the same town not reported
Replaceability none: an irreplaceable lot's warnings become hard lines none population not graded, reach not known, or change since collection not known
Mother plants under 30 plants at any step back to the collection none any step without a count; a lot pooled from several collections
Handed out handed out with fewer than six sowings left handed out not reported
Grow-out history more than three grow-outs, or a grow-out not isolated or with isolation not recorded none lineage not traceable

Details:

  • Germination. A result passes a line when the count of seedlings is at least the smallest number that would come up less than one time in twenty if the true germination were only at the line; it is clearly below when the count is at most the highest such number from below. The relative line applies once a lot has two tests. A clearly low result from seed tested within 42 days of harvest, or from feral seed, reads as a warning until a tetrazolium test or a dormancy-breaking retest confirms it, on lots that can spare the seed. A lot below the seeds-left hard line is not tested; its germination comes from the grow-out. Untested seed at room temperature, with moisture over 5% or not known, held for more than nine months is predicted to have fallen, and the prediction is labelled. No prediction ever passes.
  • Seeds left. One sowing is enough seed for 150 seedlings at the lot's latest germination. Seeds set aside from the original sample are not counted.
  • Storage. Frozen seed is retested every five years, dry seed in a fridge or cool room (7% moisture or less) every year, wetter or unmeasured seed in a fridge or cool room every four months, and dry seed at room temperature (5% or less) every year. With two tests showing a fall, the next test is brought forward to the date the fall would reach the line, if that is sooner. An untested lot's first test is due twelve months after it arrived.
  • Replaceability. A lot is irreplaceable when its population has changed since the collection (introgression now reported in the population or seen in its plants, having been absent or reported only nearby at collection; or a reported change in the plants' traits since then), is extinct in the field or extinct on-farm, can no longer be reached, or is graded High or Very high. A known change comes first. A lot with a warning and replaceability not known stays At risk and is listed as "replaceability unknown, may be Urgent".
  • Mother plants and grow-out history walk Has parent accession from the lot's accession back to its collection, at most six steps.
  • A lot with no report of any kind for five years is shown as not known to still exist and stops counting in holdings. A lot marked gone counts for nothing.

Actions and the sowing queue

Each lot lists its next actions in this order: isolation concerns; pooling or collecting again; sowing; retests and first tests; storage; backup; handing out. A lot is queued for sowing only when germination is clearly below a line or seeds left is at a warning or the hard line, and never when it was grown out without isolation or with isolation not recorded. A queued lot gets one full sowing (150 seedlings' worth, or 215 seeds when germination is not known) and the rest stays in store; a lot smaller than one sowing is sown whole and flagged as a small sowing. A lot grown out without recorded isolation that is the only seed held of its population is flagged for review as a preservation grow-out. A backup is split off only when the seed left after it stays above the seeds-left hard line; otherwise the regrown seed is backed up.

The holdings measure

Only verified copies count: lots collected from the field, or grown out with isolation documented at every step, that are not gone. Lots grown out without isolation, with isolation not recorded, or crossed between populations are shown and not counted. Two copies are in separate places only when both give a storage site, a country and a distance class, and either gives "other town" or "other country" from the other; if the two disagree, or either is missing, no separate place is credited.

  1. Urgent if no verified copy is held, or every verified copy is Urgent.
  2. At risk if every verified copy is in one town or site, only one collection is held, or the best lot is At risk.
  3. Data deficient if the best lot is Data deficient or the places are not known.
  4. Secure only with a Secure best lot and two collections whose seed has produced seedlings, held at separate places.

Lots rank from best to worst as Secure, At risk, Data deficient, Urgent. A collection whose lots are all Urgent is listed under its population whatever the population's reading.

Ranks

Lower numbers sort first. A population without a reading gets a rank that places it in its own group, never an empty value.

Rank Values
Has holdings rank Urgent 1 · At risk 2 · Secure 3 · Data deficient 8 · none 9
Has field grade rank Extinct on-farm 0 · Very high 1 · High 2 · Moderate 3 · Low 4 · Very low 5 · Near threatened 6 · Least concern 7 · Data deficient 8 · none 9 · Extinct 10
Has best lot rank Urgent 1 · Data deficient 2 · At risk 3 · Secure 4 · none 9

Grades from fields and from feral stands sort together on the bands.

Stored values

Property Set by Value
Has seed lot step lot the lot's reading
Has seed lot factors lot the factors behind the reading (the missing factors for Data deficient)
Has lot action lot the action codes, in order
Is queued for sowing, Is small sowing, Is flagged for preservation grow-out lot true or false
Has holdings step, Has live verified copy population the holdings reading; whether any verified copy is held
Has holdings rank, Has field grade rank, Has best lot rank population numbers (see Ranks)
Has method version both v0.1

Properties read

Every property name the module reads is in one table, PROPS, near the end of the module.

  • Seed lot: Is lot of, Has lot holder, Has lot role, Is safety duplicate of, Has storage site, Has storage country, Has backup distance, Has distance measured from, Has lot received date, Has original sample kept, Is handed out, Has backup status, Has lot status, Has lot status date, Has gone reason
  • Inventory and germination reports on a lot: Is report on, Has report date, Has seed count, Has seed count unit, Has thousand seed weight, Has storage regime, Has seed moisture, Has test date, Has seeds tested, Has seedlings counted, Has germination percent, Has germination basis, Has dormancy check, Has days after harvest
  • Accession: Has parent accession, Has collection, Has regrow generation, Has regrow isolation, Has parent female count, Has parent male count, Has parent plant count, Has population, Has copy status, Has collection date, Has perceived introgression level, Has classification
  • Population: Has field grade, Has field grade evidence year, Has field item set, Has reachability, Has field status; introgression assessments (Has introgression grade, Has introgression year) and trait change reports (Has trait change date) as subobjects of the population page

Constants

The method's figures are in the table C at the top of the module: the 95% confidence level, the 70% floor and the 85% relative line, 150 seedlings to a sowing, three sowings for the seeds-left hard line and six for handing out, the 3,000-seed warning, the 70% planning figure, the 500-seed backup, 30 mother plants, three grow-outs, the moisture limits (5% at room temperature, 7% in a fridge or cool room), nine months for the room-temperature prediction, twelve months to the first test, the retest intervals, five years without a report, the ten-year lapse of a field grade and the 42-day dormancy window. A stored field grade whose evidence is ten or more years old is read as Data deficient. A revision of the method changes them there and the version with them.

Test cases

Module:Grow-out priority/testcases runs the method's own cases against this module: the five invented lots of section 8.1, the holdings cases of Table 3, the germination counts of Table 4 and the default actions of Table 5, with a few further rules. A change to the module that alters any of these figures shows here as a difference.

148 of 148 cases reproduced (method v0.1).

Source Case Paper Module Result
Section 8.1 Lot 1: reading Secure Secure match
Section 8.1 Lot 1: germination (88 of 100; the floor needs 78, the relative line 84) pass pass match
Section 8.1 Lot 1: actions 0 0 match
Section 8.1 Lot 1: next test due (in three years) 2029-10-15 2029-10-15 match
Section 8.1 Lot 1, population Very High: reading Secure Secure match
Section 8.1 Lot 1, population Very High: irreplaceable true true match
Section 8.1 Lot 2: three sowings at 88% 512 512 match
Section 8.1 Lot 2: seeds left before raising warning warning match
Section 8.1 Lot 2: reading Urgent Urgent match
Section 8.1 Lot 2: reasons seeds left seeds left match
Section 8.1 Lot 2: queued for sowing true true match
Section 8.1 Lot 2: actions includes sow includes sow match
Section 8.1 Lot 3: three sowings at 88% 512 512 match
Section 8.1 Lot 3: reading Urgent Urgent match
Section 8.1 Lot 3: reasons seeds left, mother plants seeds left, mother plants match
Section 8.1 Lot 3: queued for sowing true true match
Section 8.1 Lot 3: one full sowing at 88% 171 171 match
Section 8.1 Lot 3: seeds kept in store 309 309 match
Section 8.1 Lot 3: actions includes sow includes sow match
Section 8.1 Lot 3: actions includes pool or re-collect includes pool or re-collect match
Section 8.1 Lot 3: no further test excludes first test excludes first test match
Section 8.1 Lot 3: no further test excludes retest sooner excludes retest sooner match
Section 8.1 Lot 4: reading Urgent Urgent match
Section 8.1 Lot 4: reasons includes grow-out history includes grow-out history match
Section 8.1 Lot 4: verified copy isolation not recorded isolation not recorded match
Section 8.1 Lot 4: queued for sowing false false match
Section 8.1 Lot 4: flagged for a preservation grow-out true true match
Section 8.1 Lot 4: holdings Urgent Urgent match
Section 8.1 Lot 4: live verified copy (collecting list) false false match
Section 8.1 Lot 4 with 300 seeds: queued for sowing false false match
Section 8.1 Lot 5a: reading Urgent Urgent match
Section 8.1 Lot 5a: reasons includes seeds left includes seeds left match
Section 8.1 Lot 5a: actions includes sow includes sow match
Section 8.1 Lot 5b: reading Data deficient Data deficient match
Section 8.1 Lot 5b: not known includes germination includes germination match
Section 8.1 Lot 5b: not known includes backup copy includes backup copy match
Section 8.1 Lot 5b: not known includes mother plants includes mother plants match
Section 8.1 Lot 5b: storage (frozen, low risk) pass pass match
Section 8.1 Lot 5b: actions includes first test includes first test match
Section 8.1 Lots 5a and 5b: holdings Data deficient Data deficient match
Section 8.1 Lots 5a and 5b: collections listed with every lot Urgent A5a A5a match
Table 3 No verified copy that is not confirmed dead or used up Urgent Urgent match
Table 3 No lot at all Urgent Urgent match
Table 3 Every verified copy is Urgent Urgent Urgent match
Table 3 One collection only, whatever its lots (two Secure lots) At risk At risk match
Table 3 One collection only, whatever its lots (two Data deficient lots) At risk At risk match
Table 3 All verified copies in one town At risk At risk match
Table 3 Two collections in two towns: one Urgent, one At risk At risk At risk match
Table 3 Two collections in two towns: one Urgent, one Data deficient Data deficient Data deficient match
Table 3 Two collections in two towns: one Urgent, one Data deficient: collection listed C1 C1 match
Table 3 Two collections in two towns: both At risk At risk At risk match
Table 3 Two collections in two towns: one Urgent, one Secure Secure Secure match
Table 3 Two collections in two towns: one Urgent, one Secure: collection listed C1 C1 match
Table 3 Two collections in two towns: one Secure, one Data deficient because its viability is unknown Data deficient Data deficient match
Table 3 Two collections in two towns: one Secure, one Data deficient for another missing fact, with evidenced live seed Secure Secure match
Table 3 Two collections in two towns: both Data deficient Data deficient Data deficient match
Table 3 Section 4: two Secure collections, sites not recorded Data deficient Data deficient match
Table 3 Section 4: two Secure collections, the two distance records disagree Data deficient Data deficient match
Table 4 25 seeds, 70% floor: passes 22 22 match
Table 4 25 seeds, 70% floor: clearly below 13 13 match
Table 4 25 seeds, 76.5% (85% of 90%): passes 23 23 match
Table 4 25 seeds, 76.5% (85% of 90%): clearly below 15 15 match
Table 4 25 seeds, 80.75% (85% of 95%): passes 24 24 match
Table 4 25 seeds, 80.75% (85% of 95%): clearly below 16 16 match
Table 4 50 seeds, 70% floor: passes 41 41 match
Table 4 50 seeds, 70% floor: clearly below 29 29 match
Table 4 50 seeds, 76.5% (85% of 90%): passes 44 44 match
Table 4 50 seeds, 76.5% (85% of 90%): clearly below 32 32 match
Table 4 50 seeds, 80.75% (85% of 95%): passes 46 46 match
Table 4 50 seeds, 80.75% (85% of 95%): clearly below 35 35 match
Table 4 100 seeds, 70% floor: passes 78 78 match
Table 4 100 seeds, 70% floor: clearly below 61 61 match
Table 4 100 seeds, 76.5% (85% of 90%): passes 84 84 match
Table 4 100 seeds, 76.5% (85% of 90%): clearly below 68 68 match
Table 4 100 seeds, 80.75% (85% of 95%): passes 88 88 match
Table 4 100 seeds, 80.75% (85% of 95%): clearly below 73 73 match
Table 4 200 seeds, 70% floor: passes 152 152 match
Table 4 200 seeds, 70% floor: clearly below 128 128 match
Table 4 200 seeds, 76.5% (85% of 90%): passes 164 164 match
Table 4 200 seeds, 76.5% (85% of 90%): clearly below 142 142 match
Table 4 200 seeds, 80.75% (85% of 95%): passes 171 171 match
Table 4 200 seeds, 80.75% (85% of 95%): clearly below 151 151 match
Count table, method notes 25 seeds, 70% floor: passes 22 22 match
Count table, method notes 25 seeds, 70% floor: clearly below 13 13 match
Count table, method notes 25 seeds, 76.5% (85% of 90%): passes 23 23 match
Count table, method notes 25 seeds, 76.5% (85% of 90%): clearly below 15 15 match
Count table, method notes 25 seeds, 80.75% (85% of 95%): passes 24 24 match
Count table, method notes 25 seeds, 80.75% (85% of 95%): clearly below 16 16 match
Count table, method notes 50 seeds, 70% floor: passes 41 41 match
Count table, method notes 50 seeds, 70% floor: clearly below 29 29 match
Count table, method notes 50 seeds, 76.5% (85% of 90%): passes 44 44 match
Count table, method notes 50 seeds, 76.5% (85% of 90%): clearly below 32 32 match
Count table, method notes 50 seeds, 80.75% (85% of 95%): passes 46 46 match
Count table, method notes 50 seeds, 80.75% (85% of 95%): clearly below 35 35 match
Count table, method notes 100 seeds, 70% floor: passes 78 78 match
Count table, method notes 100 seeds, 70% floor: clearly below 61 61 match
Count table, method notes 100 seeds, 76.5% (85% of 90%): passes 84 84 match
Count table, method notes 100 seeds, 76.5% (85% of 90%): clearly below 68 68 match
Count table, method notes 100 seeds, 80.75% (85% of 95%): passes 88 88 match
Count table, method notes 100 seeds, 80.75% (85% of 95%): clearly below 73 73 match
Count table, method notes 200 seeds, 70% floor: passes 152 152 match
Count table, method notes 200 seeds, 70% floor: clearly below 128 128 match
Count table, method notes 200 seeds, 76.5% (85% of 90%): passes 164 164 match
Count table, method notes 200 seeds, 76.5% (85% of 90%): clearly below 142 142 match
Count table, method notes 200 seeds, 80.75% (85% of 95%): passes 171 171 match
Count table, method notes 200 seeds, 80.75% (85% of 95%): clearly below 151 151 match
Table 5 Germination clearly below a line: queued true true match
Table 5 Germination clearly below a line includes sow includes sow match
Table 5 Germination test between the lines includes retest sooner includes retest sooner match
Table 5 Germination test between the lines: queued false false match
Table 5 A percentage below a line, with no count includes retest with counts includes retest with counts match
Table 5 A percentage below a line: germination warning warning match
Table 5 A percentage above the lines (section 5.1): germination unknown unknown match
Table 5 Never tested, kept where a fall is predicted includes test at once includes test at once match
Table 5 Never tested, kept where a fall is predicted includes cold storage includes cold storage match
Table 5 Never tested, kept where a fall is predicted: queued false false match
Table 5 Never tested, kept elsewhere includes first test includes first test match
Table 5 Seeds left below the warning line includes sow includes sow match
Table 5 Seeds left below the warning line: one full sowing at 90% 167 167 match
Table 5 Seeds left below the warning line: rest in store 1833 1833 match
Table 5 Seeds left below the hard line, untested: queued true true match
Table 5 Seeds left below the hard line, untested: sowing at the 70% planning figure 215 215 match
Table 5 Seeds left below the hard line, untested: rest in store 185 185 match
Table 5 Seeds left below the hard line: not tested first excludes first test excludes first test match
Table 5 Whole lot too small for one full sowing: small sowing true true match
Table 5 Whole lot too small for one full sowing includes sow includes sow match
Table 5 Storage at the hard line includes cold storage includes cold storage match
Table 5 The holder confirms there is no backup includes assess spare seed for a backup includes assess spare seed for a backup match
Table 5 The holder confirms there is no backup: backup copy hard hard match
Table 5 Backup status not reported includes confirm whether a backup exists includes confirm whether a backup exists match
Table 5 Backup status not reported: backup copy unknown unknown match
Table 5 Handed out with little left includes stop handing out includes stop handing out match
Table 5 Too few mother plants includes pool or re-collect includes pool or re-collect match
Table 5 Too many grow-outs (4) includes pool or re-collect includes pool or re-collect match
Table 5 Isolation not recorded includes do not queue includes do not queue match
Table 5 Isolation not recorded: queued, even with 300 seeds false false match
Table 5 Isolation not recorded, only live seed: preservation review true true match
Table 5 Isolation not recorded, another lot held: preservation review false false match
Other rules Seeds left, germination unknown: 449 hard hard match
Other rules Seeds left, germination unknown: 450 warning warning match
Other rules Seeds left, germination unknown: 2,999 warning warning match
Other rules Seeds left, germination unknown: 3,000 unknown unknown match
Other rules Stored field grade with 2014 evidence has lapsed by 2026 Data deficient Data deficient match
Other rules Stored field grade with 2022 evidence stands High High match
Other rules Stored band read without regard to case (very high): irreplaceable Urgent Urgent match
Other rules Changed population: a seeds-left warning is raised Urgent Urgent match
Other rules Old level scale at collection: change not known, so replaceability is not known unknown unknown match
Other rules Warning with replaceability not known: At risk, may be Urgent At risk true At risk true match

Version history

  • v0.1: first version, following version 0.1 of the method.

-- Module:Grow-out priority
-- Works out the readings of the grow-out priority method, version 0.1:
-- the seed lot grade, the holdings measure, the field grade band and the ranks used by
-- the priorities lists. Every rule constant is in the tables at the top, so a revision of
-- the method changes them in one place and the version stamp with them.
--
-- The rules engine works on plain Lua tables (see the documentation for their fields).
-- The SMW reader near the end builds those tables from stored properties through #ask,
-- and the entry points render a reading or store it with #set.

local p = {}

------------------------------------------------------------------------------------------
-- Rule tables
------------------------------------------------------------------------------------------

p.VERSION = 'v0.1'

local C = {
	confidence = 0.05,          -- exact binomial, one-sided, 95%
	floor = 0.70,               -- germination floor, whatever the first test
	relative = 0.85,            -- latest test against 85% of the first test
	sowingSeedlings = 150,      -- one sowing raises 150 seedlings
	sowingsHard = 3,            -- seeds left: fewer than three sowings is the hard line
	sowingsHandOut = 6,         -- handed out: fewer than six sowings left is the hard line
	seedsWarning = 3000,        -- seeds left warning line
	planningGermination = 0.70, -- a sowing for a lot with no germination figure (215 seeds)
	backupViable = 500,         -- a backup needs 500 seeds expected to germinate
	motherPlants = 30,
	maxGrowouts = 3,
	walkCap = 6,                -- lineage walk stops after this many steps
	roomMoistureMax = 5,        -- room temperature: dry seed is 5% moisture or less
	coolMoistureMax = 7,        -- fridge or cool room: dry seed is 7% or less
	predictionMonths = 9,       -- untested room-temperature seed predicted to fail after nine months
	firstTestMonths = 12,       -- first test due twelve months after the lot arrives
	retestMonths = { freezer = 60, cooldry = 12, coolwet = 4, roomdry = 12 },
	noReportYears = 5,          -- no report for five years: not known whether the lot still exists
	gradeLapseYears = 10,       -- a field grade lapses ten years after its evidence
	dormancyWindowDays = 42,    -- fresh seed tested within 42 days of harvest may be dormant
}
p.C = C

-- Reading names, from one table.
local STEP = { urgent = 'Urgent', atrisk = 'At risk', secure = 'Secure', dd = 'Data deficient' }
p.STEP = STEP

-- Factors are keyed by name, never by number.
local FACTORS = { 'germination', 'seeds left', 'storage', 'backup copy', 'replaceability',
	'mother plants', 'handed out', 'grow-out history' }
p.FACTORS = FACTORS

-- Lots from best to worst, for the holdings measure.
local LOT_ORDER = { [STEP.secure] = 1, [STEP.atrisk] = 2, [STEP.dd] = 3, [STEP.urgent] = 4 }

-- Actions, in the order they are listed on a lot.
local ACTIONS = {
	{ 'do not queue', 'Do not queue for routine sowing: isolation not recorded or pollen known to have got in' },
	{ 'preservation review', 'Review for a preservation grow-out: the only live seed of its population' },
	{ 'pool or re-collect', 'Consider pooling with another collection of the population after review, or collecting again' },
	{ 'sow', 'Queue for sowing in isolation' },
	{ 'retest sooner', 'Retest sooner' },
	{ 'retest with counts', 'Retest and record the number of seeds tested and seedlings' },
	{ 'test at once', 'Test at once and correct the storage' },
	{ 'first test', 'Test for the first time' },
	{ 'retest', 'Retest: the scheduled test is overdue' },
	{ 'cold storage', 'Dry the seed and move it to cold storage' },
	{ 'assess spare seed for a backup', 'Assess whether enough seed can be spared, and arrange a backup at another site' },
	{ 'split a backup', 'Split off a backup of at least 500 viable seeds and keep it at another site' },
	{ 'back up the regrow', 'Sow first, and back up the regrown seed' },
	{ 'confirm whether a backup exists', 'Confirm whether a backup exists' },
	{ 'stop handing out', 'Stop handing it out' },
}
local ACTION_RANK, ACTION_TEXT = {}, {}
for i, a in ipairs(ACTIONS) do ACTION_RANK[a[1]] = i; ACTION_TEXT[a[1]] = a[2] end
p.ACTION_TEXT = ACTION_TEXT

-- Field grade bands, as stored on population pages (the grade itself is worked out there).
-- Stored values are read without regard to case.
local GRADE_DD = 'Data deficient'
local BAND_NAMES = { 'Very high', 'High', 'Moderate', 'Low', 'Very low', 'Near threatened', 'Least concern', GRADE_DD }
local BAND = {}
for _, b in ipairs(BAND_NAMES) do BAND[b:lower()] = b end
local function band(v)
	if v == nil then return nil end
	return BAND[tostring(v):gsub('^%s+', ''):gsub('%s+$', ''):lower()]
end
p.band = band

-- Introgression evidence scale, lowest to highest.
local INTRO_LOW = { ['None seen or reported'] = true, ['Reported nearby'] = true }
local INTRO_HIGH = { ['Reported in this population'] = true, ['Seen in the plants'] = true }

-- Ranks stored for sorting (lower sorts first). A population with no reading gets a rank
-- that puts it in its own group, never an empty value.
local RANK = {
	holdings = { [STEP.urgent] = 1, [STEP.atrisk] = 2, [STEP.secure] = 3, [STEP.dd] = 8, none = 9 },
	-- Cultivated and feral grades sort together on the bands (provisional order).
	field = { ['Extinct on-farm'] = 0, ['Very high'] = 1, High = 2, Moderate = 3, Low = 4,
		['Very low'] = 5, ['Near threatened'] = 6, ['Least concern'] = 7, [GRADE_DD] = 8, none = 9, Extinct = 10 },
	bestLot = { [STEP.urgent] = 1, [STEP.dd] = 2, [STEP.atrisk] = 3, [STEP.secure] = 4, none = 9 },
}
p.RANK = RANK

------------------------------------------------------------------------------------------
-- Small helpers
------------------------------------------------------------------------------------------

local function trim(s) return (tostring(s or ''):gsub('^%s+', ''):gsub('%s+$', '')) end
local function blank(v) return v == nil or (type(v) == 'string' and trim(v) == '') end
local function num(v)
	if type(v) == 'number' then return v end
	if blank(v) then return nil end
	return tonumber((trim(v):gsub(',', '')))
end
local function list(v)
	if v == nil then return {} end
	if type(v) == 'table' then return v end
	if blank(v) then return {} end
	return { trim(v) }
end
local function lower(v)
	if blank(v) then return nil end
	return trim(v):lower()
end

local function split(s, sep)
	local out, i = {}, 1
	if s == '' then return out end
	while true do
		local a, b = string.find(s, sep, i, true)
		if not a then out[#out + 1] = string.sub(s, i); break end
		out[#out + 1] = string.sub(s, i, a - 1)
		i = b + 1
	end
	return out
end

-- Dates as {y, m, d}; arithmetic on day numbers, so years before 1970 work.
local function parseDate(s)
	if type(s) == 'table' then return s end
	if blank(s) then return nil end
	local y, rest = trim(s):match('^(%d%d%d%d)(.*)$')
	if not y then return nil end
	local m, d = rest:match('^%-(%d%d?)%-(%d%d?)')
	if not m then m = rest:match('^%-(%d%d?)') end
	return { y = tonumber(y), m = tonumber(m) or 1, d = tonumber(d) or 1 }
end
p.parseDate = parseDate

local function dayNumber(dt) -- days from civil (proleptic Gregorian)
	local y, m, d = dt.y, dt.m, dt.d
	if m <= 2 then y = y - 1 end
	local era = math.floor(y / 400)
	local yoe = y - era * 400
	local mp = (m + 9) % 12
	local doy = math.floor((153 * mp + 2) / 5) + d - 1
	local doe = yoe * 365 + math.floor(yoe / 4) - math.floor(yoe / 100) + doy
	return era * 146097 + doe
end

local MONTH_DAYS = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }
local function addMonths(dt, n)
	local total = dt.y * 12 + (dt.m - 1) + n
	local y, m = math.floor(total / 12), total % 12 + 1
	local leap = (y % 4 == 0 and y % 100 ~= 0) or y % 400 == 0
	local last = MONTH_DAYS[m] + ((m == 2 and leap) and 1 or 0)
	return { y = y, m = m, d = math.min(dt.d, last) }
end
local function addDays(dt, n)
	local z = dayNumber(dt) + n + 719468
	local era = math.floor(z / 146097)
	local doe = z - era * 146097
	local yoe = math.floor((doe - math.floor(doe / 1460) + math.floor(doe / 36524) - math.floor(doe / 146096)) / 365)
	local y = yoe + era * 400
	local doy = doe - (365 * yoe + math.floor(yoe / 4) - math.floor(yoe / 100))
	local mp = math.floor((5 * doy + 2) / 153)
	local d = doy - math.floor((153 * mp + 2) / 5) + 1
	local m = mp < 10 and mp + 3 or mp - 9
	if m <= 2 then y = y + 1 end
	return { y = y, m = m, d = d }
end
local function fmtDate(dt) return dt and string.format('%04d-%02d-%02d', dt.y, dt.m, dt.d) or nil end
local function before(a, b) return dayNumber(a) < dayNumber(b) end
p.fmtDate = fmtDate

local function today(ctx) return ctx.today or parseDate(os.date('!%Y-%m-%d')) end

local function fmtInt(n)
	local s = tostring(math.floor(n))
	local out = s:reverse():gsub('(%d%d%d)', '%1,'):reverse()
	return (out:gsub('^,', ''))
end

------------------------------------------------------------------------------------------
-- Exact binomial (germination results)
------------------------------------------------------------------------------------------

local logfact = { [0] = 0 }
local function lf(n)
	for i = #logfact + 1, n do logfact[i] = logfact[i - 1] + math.log(i) end
	return logfact[n]
end
local function pmf(n, k, q)
	if q <= 0 then return k == 0 and 1 or 0 end
	if q >= 1 then return k == n and 1 or 0 end
	return math.exp(lf(n) - lf(k) - lf(n - k) + k * math.log(q) + (n - k) * math.log(1 - q))
end

-- Smallest number of seedlings that passes the line q: P(X >= k | n, q) <= 5%.
-- Returns n + 1 when no result can pass.
function p.passesAt(n, q)
	local tail = 0
	for k = n, 0, -1 do
		tail = tail + pmf(n, k, q)
		if tail > C.confidence then return k + 1 end
	end
	return 0
end

-- Largest number of seedlings that is clearly below the line q: P(X <= k | n, q) <= 5%.
-- Returns -1 when no result is clearly below.
function p.clearlyBelowAt(n, q)
	local tail = 0
	for k = 0, n do
		tail = tail + pmf(n, k, q)
		if tail > C.confidence then return k - 1 end
	end
	return n
end

-- 'pass', 'between' or 'below' for one counted result against one line.
function p.judge(seedlings, n, q)
	if seedlings >= p.passesAt(n, q) then return 'pass' end
	if seedlings <= p.clearlyBelowAt(n, q) then return 'below' end
	return 'between'
end

------------------------------------------------------------------------------------------
-- Field grade lapse (the grade is worked out by Module:Population; a stored grade goes stale
-- with time, so the lapse is applied again when it is read)
------------------------------------------------------------------------------------------

-- A grade whose evidence is ten or more years old reads Data deficient until reassessed.
function p.lapse(r, asof)
	local now = parseDate(asof)
	r.band = band(r.band) or r.band
	if r.band and r.band ~= GRADE_DD and r.evidenceYear and now
		and now.y >= r.evidenceYear + C.gradeLapseYears then
		r.lapsedFrom, r.lapsedYear = r.band, r.evidenceYear + C.gradeLapseYears
		r.band = GRADE_DD
	end
	return r
end

------------------------------------------------------------------------------------------
-- Lineage
------------------------------------------------------------------------------------------

-- Walks Has parent accession from an accession back to its collection.
-- Returns {steps = {accession, parent, ..., root}, complete, pooled, capped, gap}.
function p.walk(accId, data)
	local w = { steps = {} }
	local seen, cur = {}, accId
	for _ = 1, C.walkCap + 1 do
		local a = cur and data.accessions[cur]
		if not a then w.gap = cur or 'accession'; return w end
		if seen[cur] then w.gap = cur; return w end
		seen[cur] = true
		w.steps[#w.steps + 1] = a
		local parents = list(a.parents)
		if #parents > 1 or #list(a.collection) > 1 then w.pooled = true; return w end
		if #parents == 0 then
			if (num(a.generation) or 0) > 0 then w.gap = a.id; return w end
			w.complete, w.root = true, a
			return w
		end
		cur = parents[1]
	end
	w.capped = true
	return w
end

local function plantCount(a)
	local t = num(a.parent_total)
	if t then return t end
	local f, m = num(a.parent_females), num(a.parent_males)
	if f and m then return f + m end
	return nil
end

------------------------------------------------------------------------------------------
-- Lot inputs
------------------------------------------------------------------------------------------

local function byDate(t)
	local dated = {}
	for i, r in ipairs(t or {}) do dated[#dated + 1] = { r = r, d = parseDate(r.date), i = i } end
	table.sort(dated, function(a, b)
		if a.d and b.d then
			local x, y = dayNumber(a.d), dayNumber(b.d)
			if x ~= y then return x < y end
			return a.i < b.i
		end
		if a.d or b.d then return b.d ~= nil end -- undated first
		return a.i < b.i
	end)
	local out = {}
	for _, x in ipairs(dated) do out[#out + 1] = x.r end
	return out
end

-- Germination figure of one test: proportion, and the counts when given.
local function testFigure(t)
	local n, s = num(t.seeds_tested), num(t.seedlings)
	if n and s and n > 0 then return s / n, n, s end
	local pc = num(t.percent)
	if pc then return pc / 100 end
	return nil
end

local function realTests(lot)
	local out = {}
	for _, t in ipairs(byDate(lot.tests)) do
		if lower(t.basis) ~= 'prediction' and testFigure(t) then out[#out + 1] = t end
	end
	return out
end

local function storageKey(inv)
	local s = lower(inv and inv.storage)
	if not s then return nil end
	if s:find('freez') then return 'freezer' end
	if s:find('fridge') or s:find('cool') then return 'cool' end
	if s:find('room') then return 'room' end
	return 'other'
end

-- Seeds needed for `seedlings` seedlings at the lot's germination (rounded up).
local function seedsFor(seedlings, st)
	if st.gn and st.gs then
		if st.gs == 0 then return math.huge end
		return math.floor((seedlings * st.gn + st.gs - 1) / st.gs)
	end
	local g = st.g or C.planningGermination
	if g <= 0 then return math.huge end
	return math.ceil(seedlings / g - 1e-9)
end
p.seedsFor = seedsFor

local function lotInputs(lot, data, ctx)
	local st = {}
	st.tests = realTests(lot)
	local latest = st.tests[#st.tests]
	if latest then
		st.g, st.gn, st.gs = testFigure(latest)
		st.latestTest = latest
	end
	local inv = byDate(lot.inventory)
	st.inv = inv[#inv]
	if st.inv then
		local q = num(st.inv.quantity)
		local unit = lower(st.inv.quantity_unit) or 'seeds'
		if q and unit == 'g' then
			local tsw = num(st.inv.thousand_seed_weight)
			q = (tsw and tsw > 0) and math.floor(q * 1000 / tsw) or nil
		end
		if q then
			st.count = q
			st.usable = q - (num(lot.original_kept) or 0)
		end
	end
	-- Last report of any kind.
	local last
	for _, r in ipairs(lot.inventory or {}) do
		local d = parseDate(r.date); if d and (not last or before(last, d)) then last = d end
	end
	for _, r in ipairs(lot.tests or {}) do
		local d = parseDate(r.date); if d and (not last or before(last, d)) then last = d end
	end
	local rec = parseDate(lot.received_date)
	if rec and (not last or before(last, rec)) then last = rec end
	st.lastReport = last
	st.walk = p.walk(lot.accession, data)
	st.accession = data.accessions[lot.accession]
	return st
end

------------------------------------------------------------------------------------------
-- Factors. Each returns {state = 'pass' | 'warning' | 'hard' | 'unknown', text = ...}.
------------------------------------------------------------------------------------------

local F = {}

F['seeds left'] = function(lot, st)
	if not st.count then return { state = 'unknown', text = 'seed count not reported' } end
	local n = st.usable
	if st.g then
		local hard = seedsFor(C.sowingSeedlings * C.sowingsHard, st)
		st.threeSowings = hard
		if n < hard then
			return { state = 'hard', text = string.format('%s seeds left, under three sowings (%s at %d%% germination)',
				fmtInt(n), fmtInt(hard), math.floor(st.g * 100 + 0.5)) }
		elseif n < C.seedsWarning then
			return { state = 'warning', text = string.format('%s seeds left, under %s', fmtInt(n), fmtInt(C.seedsWarning)) }
		end
		return { state = 'pass', text = fmtInt(n) .. ' seeds left' }
	end
	local atFull = C.sowingSeedlings * C.sowingsHard
	if n < atFull then
		return { state = 'hard', text = string.format('%s seeds left, under three sowings even at full germination', fmtInt(n)) }
	elseif n < C.seedsWarning then
		return { state = 'warning', hardUnknown = true,
			text = string.format('%s seeds left, under %s; germination not known', fmtInt(n), fmtInt(C.seedsWarning)) }
	end
	return { state = 'unknown', text = fmtInt(n) .. ' seeds left; germination not known' }
end

local function predictedFall(lot, st, ctx)
	if storageKey(st.inv) ~= 'room' then return false end
	local m = num(st.inv.seed_moisture)
	if m and m <= C.roomMoistureMax then return false end
	local since = parseDate(lot.received_date)
	if not since then
		for _, r in ipairs(byDate(lot.inventory)) do since = parseDate(r.date); if since then break end end
	end
	if not since then return false end
	return before(addMonths(since, C.predictionMonths), today(ctx))
end

F['germination'] = function(lot, st, ctx, data)
	local tests = st.tests
	if #tests == 0 then
		if st.seedsHard then
			st.untestable = true
			return { state = 'unknown', text = 'not tested: too few seeds to spare; germination is read from the grow-out' }
		end
		if predictedFall(lot, st, ctx) then
			st.germEvidence = 'predicted'
			return { state = 'hard', predicted = true,
				text = 'never tested; predicted to have fallen (room temperature, moisture over 5% or not known, held over nine months)' }
		end
		st.germEvidence = 'never tested'
		return { state = 'unknown', text = 'never tested' }
	end
	local latest, first = tests[#tests], tests[1]
	local lines = { { q = C.floor, label = '70% floor' } }
	if #tests >= 2 then
		local fq = testFigure(first)
		lines[#lines + 1] = { q = C.relative * fq, label = string.format('85%% of the first test (%s%%)',
			tostring(math.floor(C.relative * fq * 10000 + 0.5) / 100)) }
	end
	local q, n, s = testFigure(latest)
	if not n then
		for _, l in ipairs(lines) do
			if q < l.q then
				st.germEvidence = 'percentage below'
				return { state = 'warning', text = string.format('%s%% (percentage only), below the %s; counts needed',
					tostring(math.floor(q * 1000 + 0.5) / 10), l.label) }
			end
		end
		st.germEvidence = 'percentage only'
		return { state = 'unknown', text = 'percentage only; counts needed' }
	end
	local worst, worstLine = 'pass', nil
	local order = { pass = 0, between = 1, below = 2 }
	for _, l in ipairs(lines) do
		local j = p.judge(s, n, l.q)
		if order[j] > order[worst] then worst, worstLine = j, l end
	end
	local what = string.format('%d seedlings from %d seeds', s, n)
	if worst == 'below' then
		-- Fresh or feral seed can be dormant: a low result counts once a tetrazolium test or a
		-- dormancy-breaking retest confirms it, on lots that can spare the seed.
		local dah = num(latest.days_after_harvest)
		local feral = st.accession and lower(st.accession.classification) == 'feral'
		local checked = lower(latest.dormancy_check)
		local confirmed = checked and checked ~= 'none'
		if (feral or (dah and dah <= C.dormancyWindowDays)) and not confirmed and not st.seedsHard then
			st.germEvidence = 'between'
			return { state = 'warning', text = what .. ', below the ' .. worstLine.label .. '; not yet checked for dormancy' }
		end
		st.germEvidence = 'clearly below'
		return { state = 'hard', text = what .. ', clearly below the ' .. worstLine.label }
	elseif worst == 'between' then
		st.germEvidence = 'between'
		return { state = 'warning', text = what .. ', between the lines for the ' .. worstLine.label }
	end
	st.germEvidence = 'passes'
	st.evidencedLive = true
	return { state = 'pass', text = what .. ', passes' }
end

F['storage'] = function(lot, st, ctx)
	local key = storageKey(st.inv)
	if not key or key == 'other' then return { state = 'unknown', text = 'storage not reported' } end
	local m = num(st.inv.seed_moisture)
	local months
	if key == 'freezer' then months = C.retestMonths.freezer
	elseif key == 'cool' then months = (m and m <= C.coolMoistureMax) and C.retestMonths.cooldry or C.retestMonths.coolwet
	else
		if not (m and m <= C.roomMoistureMax) then
			return { state = 'hard', text = 'room temperature with moisture over 5% or not known' }
		end
		months = C.retestMonths.roomdry
	end
	local now = today(ctx)
	local due
	if #st.tests > 0 then
		local last = parseDate(st.tests[#st.tests].date)
		if last then
			due = addMonths(last, months)
			-- After two tests the lot's own rate of fall can bring the date forward.
			if #st.tests >= 2 then
				local f1, l1 = st.tests[1], st.tests[#st.tests]
				local d1, d2 = parseDate(f1.date), last
				local q1, q2 = testFigure(f1), testFigure(l1)
				if d1 and q2 < q1 and dayNumber(d2) > dayNumber(d1) then
					local rate = (q1 - q2) / (dayNumber(d2) - dayNumber(d1))
					local line = math.max(C.floor, C.relative * q1)
					local days = math.max(0, math.floor((q2 - line) / rate))
					local cross = addDays(d2, days)
					if before(cross, due) then due = cross end
				end
			end
		end
	elseif not st.seedsHard then
		local rec = parseDate(lot.received_date)
		if rec then due = addMonths(rec, C.firstTestMonths) end
	end
	st.nextTest = due
	local regime = (key == 'freezer') and 'frozen' or (key == 'cool' and 'fridge or cool room' or 'room temperature, dry')
	if #st.tests == 0 and st.seedsHard then
		return { state = 'pass', text = regime .. '; not tested (too few seeds to spare)' }
	end
	if not due then return { state = 'unknown', text = regime .. '; test date not known' } end
	if before(due, now) then
		if #st.tests == 0 then
			st.overdueFirst = true
			return { state = 'warning', text = regime .. '; never tested, first test was due ' .. fmtDate(due) }
		end
		st.overdue = true
		return { state = 'warning', text = regime .. '; test overdue since ' .. fmtDate(due) }
	end
	if #st.tests == 0 then
		return { state = 'pass', text = regime .. '; low risk, first test due ' .. fmtDate(due) }
	end
	return { state = 'pass', text = regime .. '; next test due ' .. fmtDate(due) }
end

local function copyReading(other, data, ctx)
	-- What a copy of this lot is worth as a backup: 'pass', 'warning' or 'unknown'.
	local ost = lotInputs(other, data, ctx)
	local dist = lower(other.backup_distance)
	local where
	if dist == 'same site' or dist == 'same town' then where = 'warning'
	elseif dist == 'other town' or dist == 'other country' then where = 'pass'
	else where = 'unknown' end
	local viable
	if ost.usable and ost.g then viable = ost.usable * ost.g end
	local size
	if viable then size = viable >= C.backupViable and 'pass' or 'warning'
	elseif ost.usable and ost.usable < C.backupViable then size = 'warning'
	else size = 'unknown' end
	local state = 'pass'
	if where == 'warning' or size == 'warning' then state = 'warning'
	elseif where == 'unknown' or size == 'unknown' then state = 'unknown' end
	local parts = {}
	parts[#parts + 1] = viable and (fmtInt(math.floor(viable)) .. ' seeds expected to germinate')
		or (ost.usable and (fmtInt(ost.usable) .. ' seeds, germination not known') or 'size not reported')
	parts[#parts + 1] = dist or 'distance not recorded'
	return state, table.concat(parts, ', ')
end

F['backup copy'] = function(lot, st, ctx, data)
	local copies = {}
	for _, other in pairs(data.lots) do
		if other ~= lot and lower(other.lot_status) ~= 'gone' then
			if lower(other.lot_role) == 'safety duplicate' and other.duplicate_of == lot.id then
				copies[#copies + 1] = other
			end
		end
	end
	-- A safety duplicate's own copy is the lot it backs up.
	if lower(lot.lot_role) == 'safety duplicate' and lot.duplicate_of and data.lots[lot.duplicate_of] then
		copies[#copies + 1] = data.lots[lot.duplicate_of]
	end
	if #copies > 0 then
		local best, bestText, anyWarn, warnText = nil, nil, false, nil
		for _, c in ipairs(copies) do
			local s, t = copyReading(c, data, ctx)
			if s == 'pass' then best, bestText = 'pass', t end
			if s == 'warning' then anyWarn, warnText = true, t end
			if s == 'unknown' and not best then bestText = bestText or t end
		end
		if best == 'pass' then return { state = 'pass', text = 'backup: ' .. bestText } end
		if anyWarn then return { state = 'warning', text = 'backup: ' .. warnText } end
		return { state = 'unknown', text = 'backup: ' .. (bestText or 'not reported') }
	end
	if lower(lot.backup_status) == 'confirmed none' then
		st.backupNone = true
		return { state = 'hard', text = 'no backup copy' }
	end
	st.backupUnreported = true
	return { state = 'unknown', text = 'backup not reported' }
end

-- Whether the population has changed since the lot's collection: true, false or nil (not known).
local function changedSinceCollection(pop, root)
	if not pop then return false end
	local result = false
	local intro = byDate(pop.introgression)
	local current = intro[#intro]
	if current and INTRO_HIGH[trim(current.grade)] then
		local at = root and trim(root.introgression_at_collection or '')
		if INTRO_LOW[at] then return true end
		if not INTRO_HIGH[at] then result = nil end
	end
	local cdate = root and parseDate(root.collection_date)
	for _, tc in ipairs(pop.trait_changes or {}) do
		local d = parseDate(tc.date)
		if d and cdate then
			if before(cdate, d) then return true end
		else
			result = nil
		end
	end
	return result
end

F['replaceability'] = function(lot, st, ctx, data)
	local acc = st.accession
	local root = st.walk.root or acc
	local popId = (acc and acc.population) or (root and root.population)
	local pop = popId and data.populations[popId]
	if not pop then
		st.replaceability = 'unknown'
		return { state = 'unknown', text = 'population not known' }
	end
	local changed = changedSinceCollection(pop, root)
	local function irr(why) st.irreplaceable = why; st.replaceability = 'irreplaceable'
		return { state = 'pass', irreplaceable = true, text = 'irreplaceable: ' .. why } end
	if changed == true then return irr('population changed since collection') end
	local fs = trim(pop.field_status or '')
	if fs == 'Extinct' or fs == 'Extinct on-farm' then return irr(fs:lower()) end
	local reach = lower(pop.reachability)
	if reach == 'not reachable' then return irr('can no longer be reached') end
	local b = band(pop.band)
	if b == 'Very high' or b == 'High' then return irr('population graded ' .. b) end
	st.replaceability = 'unknown'
	if changed == nil then return { state = 'unknown', text = 'whether the population has changed is not known' } end
	if not b or b == GRADE_DD then return { state = 'unknown', text = 'population not graded' } end
	if reach ~= 'reachable' and reach ~= 'with difficulty' then
		return { state = 'unknown', text = 'whether the population can be reached is not known' }
	end
	st.replaceability = 'replaceable'
	return { state = 'pass', text = 'replaceable (population ' .. b .. (reach == 'with difficulty' and ', reached with difficulty' or '') .. ')' }
end

F['mother plants'] = function(lot, st)
	local w = st.walk
	if w.pooled then return { state = 'unknown', text = 'pooled from several collections; mother plants not settled' } end
	local min, minGen, missing = nil, nil, false
	local depth = #w.steps
	for i, a in ipairs(w.steps) do
		local c = plantCount(a)
		local gen = num(a.generation) or (w.complete and (depth - i)) or nil
		if c then
			if not min or c < min then min, minGen = c, gen end
		else
			missing = true
		end
	end
	if min and min < C.motherPlants then
		st.cureByPooling = true
		local where = minGen and (minGen == 0 and ' (the collection)' or (' (grow-out ' .. minGen .. ')')) or ''
		local tail = (missing or not w.complete) and '; earlier steps not recorded' or ''
		return { state = 'hard', text = 'collected from ' .. min .. ' plants' .. where .. tail }
	end
	if missing or not w.complete or not min then return { state = 'unknown', text = 'mother plants not recorded' } end
	return { state = 'pass', text = min .. ' plants at the smallest step' }
end

F['handed out'] = function(lot, st)
	local h = list(lot.handed_out)
	if #h == 0 then return { state = 'unknown', text = 'not reported whether it is handed out' } end
	local out = false
	for _, v in ipairs(h) do if lower(v) ~= 'no' then out = true end end
	if not out then return { state = 'pass', text = 'not handed out' } end
	if st.usable then
		local six = st.g and seedsFor(C.sowingSeedlings * C.sowingsHandOut, st) or (C.sowingSeedlings * C.sowingsHandOut)
		if st.usable < six then
			return { state = 'hard', text = string.format('handed out with %s seeds left, under six sowings', fmtInt(st.usable)) }
		end
	end
	return { state = 'warning', text = 'handed out (' .. table.concat(h, ', ') .. ')' }
end

F['grow-out history'] = function(lot, st)
	local w = st.walk
	local isolation = 'documented'
	for i, a in ipairs(w.steps) do
		local isRegrow = #list(a.parents) > 0 or (num(a.generation) or 0) > 0
		if isRegrow then
			local iso = lower(a.regrow_isolation)
			if iso == 'not isolated' then isolation = 'pollen known in'
			elseif not iso and isolation == 'documented' then isolation = 'not recorded' end
		end
	end
	local gens
	if w.complete then gens = #w.steps - 1 else gens = st.accession and num(st.accession.generation) end
	st.generations = gens
	if lower(st.accession and st.accession.copy_status) == 'cross between populations' then st.verified = 'cross'
	elseif isolation == 'pollen known in' then st.verified = 'pollen known in'
	elseif isolation == 'not recorded' then st.verified = 'isolation not recorded'
	elseif w.complete or w.pooled then st.verified = gens == 0 and 'field collection' or 'documented isolation'
	else st.verified = 'lineage not traceable' end
	if isolation == 'pollen known in' then
		st.isolationProblem = true
		return { state = 'hard', text = 'grown out without isolation' }
	elseif isolation == 'not recorded' then
		st.isolationProblem = true
		return { state = 'hard', text = 'grown out; isolation not recorded' }
	end
	if gens and gens > C.maxGrowouts then
		st.cureByPooling = true
		return { state = 'hard', text = gens .. ' grow-outs from the collection, more than three' }
	end
	if gens == 0 then return { state = 'pass', text = 'collected from the field' } end
	if gens then
		local ord = ({ '1st', '2nd', '3rd' })[gens] or (gens .. 'th')
		return { state = 'pass', text = ord .. ' isolated grow-out' }
	end
	return { state = 'unknown', text = 'grow-out history not traceable' }
end

------------------------------------------------------------------------------------------
-- The seed lot grade
------------------------------------------------------------------------------------------

local EVAL_ORDER = { 'seeds left', 'germination', 'storage', 'backup copy', 'replaceability',
	'mother plants', 'handed out', 'grow-out history' }

-- Lots of one population, for the preservation check.
local function populationOf(lot, data)
	local a = data.accessions[lot.accession]
	return a and a.population
end

function p.evaluateLot(lotId, data, ctx)
	ctx = ctx or {}
	local lot = data.lots[lotId]
	local r = { id = lotId, lot = lot, version = p.VERSION, asof = fmtDate(today(ctx)), factors = {} }
	if not lot then r.error = 'lot not found'; return r end
	if lower(lot.lot_status) == 'gone' then
		r.gone = true
		r.goneText = 'Gone' .. (lot.gone_reason and (' (' .. lot.gone_reason .. ')') or '')
			.. (lot.lot_status_date and (', ' .. lot.lot_status_date) or '')
		return r
	end
	local st = lotInputs(lot, data, ctx)
	r.st = st
	-- Seeds left first: it decides whether the lot is tested at all.
	r.factors['seeds left'] = F['seeds left'](lot, st, ctx, data)
	st.seedsHard = r.factors['seeds left'].state == 'hard'
	for _, name in ipairs(EVAL_ORDER) do
		if name ~= 'seeds left' then r.factors[name] = F[name](lot, st, ctx, data) end
	end
	-- Not known whether it still exists.
	if st.lastReport and before(addMonths(st.lastReport, 12 * C.noReportYears), today(ctx)) then
		r.existsUnknown = true
		r.lastReport = fmtDate(st.lastReport)
	end
	-- Combine. The replaceability factor raises warnings; it never sets a reading by itself.
	local hard, warn, unknown = {}, {}, {}
	for _, name in ipairs(FACTORS) do
		local f = r.factors[name]
		local state = f.state
		if state == 'warning' and st.irreplaceable then
			state = 'hard'; f.raised = true
		end
		f.effective = state
		if state == 'hard' then hard[#hard + 1] = name
		elseif state == 'warning' then warn[#warn + 1] = name
		elseif state == 'unknown' then unknown[#unknown + 1] = name end
	end
	r.irreplaceable = st.irreplaceable
	r.reasons, r.missing = {}, unknown
	if #hard > 0 then
		r.step = STEP.urgent
		-- Among Urgent lots, a germination problem comes before a low seed count.
		table.sort(hard, function(a, b)
			local ia, ib
			for i, n in ipairs(FACTORS) do if n == a then ia = i end; if n == b then ib = i end end
			return ia < ib
		end)
		r.reasons = hard
	elseif #warn > 0 then
		r.step = STEP.atrisk
		r.reasons = warn
		if st.replaceability == 'unknown' then r.mayBeUrgent = true end
	elseif #unknown > 0 then
		r.step = STEP.dd
	else
		r.step = STEP.secure
	end
	r.verified = st.verified
	r.counting = (st.verified == 'field collection' or st.verified == 'documented isolation') and not r.existsUnknown
	r.evidencedLive = st.evidencedLive or false
	r.nextTest = fmtDate(st.nextTest)
	r.threeSowings = st.threeSowings
	r.generations = st.generations
	p.actions(r, lot, st, data)
	return r
end

-- Actions and the sowing queue (manuscript Table 5).
function p.actions(r, lot, st, data)
	local acts, seen = {}, {}
	local function add(code, detail)
		if seen[code] then return end
		seen[code] = true
		acts[#acts + 1] = { code = code, text = ACTION_TEXT[code], detail = detail }
	end
	local f = r.factors
	if st.isolationProblem then
		add('do not queue')
		-- The only live seed of its population: no other lot of it is held.
		local pop = populationOf(lot, data)
		if pop then
			local other = false
			for id, o in pairs(data.lots) do
				if id ~= lot.id and lower(o.lot_status) ~= 'gone' and populationOf(o, data) == pop then other = true end
			end
			r.preservation = not other
			if r.preservation then add('preservation review') end
		end
	end
	if st.cureByPooling then add('pool or re-collect') end
	-- Queue for sowing: only a clear germination failure or a low seed count, never an
	-- unisolated grow-out.
	local seedsReason = f['seeds left'].state == 'hard' or f['seeds left'].state == 'warning'
	local germReason = st.germEvidence == 'clearly below'
	r.queued = (seedsReason or germReason) and not st.isolationProblem
	if r.queued then
		local sowing = seedsFor(C.sowingSeedlings, st)
		r.sowing = sowing
		local total = st.count or 0
		if st.count and total < sowing then
			r.smallSowing = true
			add('sow', string.format('small sowing: sow the whole lot (%s seeds) in isolation; pooling with another lot of the same population can be considered after review, each parent recorded separately', fmtInt(total)))
		else
			local g = st.g and (string.format('%d%%', math.floor(st.g * 100 + 0.5))) or 'the 70% planning figure'
			local detail = string.format('one full sowing, %s seeds at %s', fmtInt(sowing), g)
			if st.usable then
				r.rest = math.max(0, st.usable - sowing)
				detail = detail .. string.format('; keep the other %s in store', fmtInt(r.rest))
			end
			if st.seedsHard then detail = detail .. '; no test first' end
			add('sow', detail)
		end
	end
	r.smallSowing = r.smallSowing or false
	if not st.untestable then
		local ev = st.germEvidence
		if ev == 'between' then add('retest sooner')
		elseif ev == 'percentage below' then add('retest with counts')
		elseif ev == 'predicted' then add('test at once')
		elseif ev == 'never tested' then add('first test')
		end
		if st.overdue then add('retest') end
	end
	if f['storage'].state == 'hard' then add('cold storage') end
	-- A backup is split off only if the seed left stays above the hard line.
	local canSplit = not st.seedsHard
	if canSplit and st.usable and st.threeSowings and st.g then
		canSplit = st.usable - seedsFor(C.backupViable, st) >= st.threeSowings
	end
	if st.backupNone then add(canSplit and 'assess spare seed for a backup' or 'back up the regrow')
	elseif f['backup copy'].state == 'warning' then add(canSplit and 'split a backup' or 'back up the regrow')
	elseif st.backupUnreported then add('confirm whether a backup exists') end
	if f['handed out'].effective == 'hard' then add('stop handing out') end
	table.sort(acts, function(a, b) return ACTION_RANK[a.code] < ACTION_RANK[b.code] end)
	r.actions = acts
	return acts
end

------------------------------------------------------------------------------------------
-- Holdings
------------------------------------------------------------------------------------------

local function siteRecorded(e)
	return not blank(e.site) and not blank(e.country) and not blank(e.distance)
end

-- How two copies stand: 'separate', 'same' or nil (not known).
local function pairPlace(a, b)
	if not (siteRecorded(a) and siteRecorded(b)) then return nil end
	if trim(a.site) == trim(b.site) then return 'same' end
	local function rec(x, y)
		if x.distanceFrom ~= y.id then return nil end
		local d = lower(x.distance)
		if d == 'other town' or d == 'other country' then return 'separate' end
		if d == 'same site' or d == 'same town' then return 'same' end
	end
	local ab, ba = rec(a, b), rec(b, a)
	if ab and ba then return ab == ba and ab or nil end
	return ab or ba
end
p.pairPlace = pairPlace

-- entries: one per lot of the population, {id, step, collection, counting, evidencedLive,
-- site, country, distance, distanceFrom}. Returns the holdings reading.
function p.readHoldings(entries)
	local h = { collections = {}, counting = {}, listed = {} }
	for _, e in ipairs(entries) do
		if e.counting then h.counting[#h.counting + 1] = e end
	end
	h.liveVerifiedCopy = #h.counting > 0
	-- Collections, and the best lot of each.
	local colls, nColl = {}, 0
	for _, e in ipairs(h.counting) do
		local c = e.collection or e.id
		if not colls[c] then colls[c] = { id = c, lots = {} }; nColl = nColl + 1 end
		table.insert(colls[c].lots, e)
	end
	for _, c in pairs(colls) do
		local best, allUrgent = nil, true
		for _, e in ipairs(c.lots) do
			if not best or LOT_ORDER[e.step] < LOT_ORDER[best] then best = e.step end
			if e.step ~= STEP.urgent then allUrgent = false end
		end
		c.step = best
		h.collections[#h.collections + 1] = c
		if allUrgent then h.listed[#h.listed + 1] = c end
	end
	table.sort(h.collections, function(a, b) return tostring(a.id) < tostring(b.id) end)
	table.sort(h.listed, function(a, b) return tostring(a.id) < tostring(b.id) end)
	h.nCollections = nColl
	if #h.counting == 0 then
		h.step, h.why = STEP.urgent, 'no verified copy held'
		return h
	end
	local best, allUrgent = nil, true
	for _, e in ipairs(h.counting) do
		if not best or LOT_ORDER[e.step] < LOT_ORDER[best] then best = e.step end
		if e.step ~= STEP.urgent then allUrgent = false end
	end
	h.best = best
	if allUrgent then h.step, h.why = STEP.urgent, 'every verified copy is Urgent'; return h end
	-- Places.
	local anySeparate, allSame = false, true
	local securePair = false
	for i = 1, #h.counting do
		for j = i + 1, #h.counting do
			local a, b = h.counting[i], h.counting[j]
			local rel = pairPlace(a, b)
			if rel == 'separate' then
				anySeparate = true
				if a.evidencedLive and b.evidencedLive and (a.collection or a.id) ~= (b.collection or b.id) then
					securePair = true
				end
			end
			if rel ~= 'same' then allSame = false end
		end
	end
	if #h.counting == 1 then allSame = siteRecorded(h.counting[1]) end
	h.places = anySeparate and 'separate' or (allSame and 'one' or 'not known')
	if allSame then h.step, h.why = STEP.atrisk, 'every verified copy in one town or site'; return h end
	if nColl == 1 then h.step, h.why = STEP.atrisk, 'one collection only'; return h end
	if best == STEP.atrisk then h.step, h.why = STEP.atrisk, 'best lot At risk'; return h end
	if best == STEP.dd then h.step, h.why = STEP.dd, 'best lot Data deficient'; return h end
	if not anySeparate then h.step, h.why = STEP.dd, 'storage sites not recorded'; return h end
	if not securePair then
		h.step, h.why = STEP.dd, 'not two collections with evidenced live seed at separate sites'
		return h
	end
	h.step, h.why = STEP.secure, nil
	return h
end

-- The holdings reading of a population from the lots in `data`.
function p.evaluatePopulation(popId, data, ctx)
	ctx = ctx or {}
	local out = { id = popId, lots = {}, entries = {}, version = p.VERSION, asof = fmtDate(today(ctx)) }
	local ids = {}
	for id, lot in pairs(data.lots) do
		if populationOf(lot, data) == popId then ids[#ids + 1] = id end
	end
	table.sort(ids)
	for _, id in ipairs(ids) do
		local r = p.evaluateLot(id, data, ctx)
		out.lots[#out.lots + 1] = r
		if not r.gone then
			local lot = r.lot
			local w = r.st and r.st.walk
			local root = w and w.root
			out.entries[#out.entries + 1] = {
				id = id, step = r.step, counting = r.counting, evidencedLive = r.evidencedLive,
				collection = root and root.id or lot.accession,
				site = lot.storage_site, country = lot.storage_country, distance = lot.backup_distance,
				distanceFrom = lot.distance_measured_from,
			}
		end
	end
	out.holdings = p.readHoldings(out.entries)
	local pop = data.populations[popId] or {}
	out.band = pop.band
	out.ranks = p.ranks(out.holdings.step, (pop.field_status == 'Extinct' or pop.field_status == 'Extinct on-farm')
		and pop.field_status or pop.band, out.holdings.best)
	return out
end

function p.ranks(holdingsStep, fieldBand, bestLot)
	return {
		holdings = RANK.holdings[holdingsStep or 'none'] or RANK.holdings.none,
		field = RANK.field[band(fieldBand) or fieldBand or 'none'] or RANK.field.none,
		bestLot = RANK.bestLot[bestLot or 'none'] or RANK.bestLot.none,
	}
end

------------------------------------------------------------------------------------------
-- SMW reader: builds the engine's tables from stored properties through #ask.
------------------------------------------------------------------------------------------

-- Every property the module reads, in one table.
local P = {
	-- seed lot (page or subobject)
	lot_of = 'Is lot of', holder = 'Has lot holder', lot_role = 'Has lot role',
	duplicate_of = 'Is safety duplicate of', storage_site = 'Has storage site',
	storage_country = 'Has storage country', backup_distance = 'Has backup distance',
	distance_measured_from = 'Has distance measured from', received_date = 'Has lot received date',
	original_kept = 'Has original sample kept', handed_out = 'Is handed out',
	backup_status = 'Has backup status', lot_status = 'Has lot status',
	lot_status_date = 'Has lot status date', gone_reason = 'Has gone reason',
	-- reports on a lot (subobjects)
	report_on = 'Is report on',
	report_date = 'Has report date', seed_count = 'Has seed count', seed_count_unit = 'Has seed count unit',
	thousand_seed_weight = 'Has thousand seed weight', storage_regime = 'Has storage regime',
	seed_moisture = 'Has seed moisture',
	test_date = 'Has test date', seeds_tested = 'Has seeds tested', seedlings = 'Has seedlings counted',
	germination_percent = 'Has germination percent', germination_basis = 'Has germination basis',
	dormancy_check = 'Has dormancy check', days_after_harvest = 'Has days after harvest',
	-- accession
	parent = 'Has parent accession', collection = 'Has collection', generation = 'Has regrow generation',
	isolation = 'Has regrow isolation', females = 'Has parent female count', males = 'Has parent male count',
	total = 'Has parent plant count', population = 'Has population', copy_status = 'Has copy status',
	collection_date = 'Has collection date', introgression = 'Has perceived introgression level',
	classification = 'Has classification',
	-- population
	field_grade = 'Has field grade', grade_year = 'Has field grade evidence year',
	item_set = 'Has field item set', reachability = 'Has reachability', field_status = 'Has field status',
	intro_grade = 'Has introgression grade', intro_year = 'Has introgression year',
	trait_date = 'Has trait change date',
}
p.PROPS = P

local SEP_R, SEP_P, SEP_V = '⟦R⟧', '⟦P⟧', '⟦V⟧'

-- printouts: list of {key, property, format}; returns list of {subject, fields = {key = {values}}}.
local function ask(frame, cond, printouts, limit)
	local parts = { '{{#ask:', cond }
	for _, po in ipairs(printouts) do
		parts[#parts + 1] = '|?' .. po[2] .. '#' .. (po[3] or '-') .. '=⟪' .. po[1] .. '⟫'
	end
	parts[#parts + 1] = '|format=plainlist|headers=plain|link=none|searchlabel=|default='
	parts[#parts + 1] = '|sep=' .. SEP_R .. '|propsep=' .. SEP_P .. '|valuesep=' .. SEP_V
	parts[#parts + 1] = '|limit=' .. (limit or 500) .. '}}'
	local out = trim(frame:preprocess(table.concat(parts)))
	local rows = {}
	if out == '' then return rows end
	for _, row in ipairs(split(out, SEP_R)) do
		local subject, rest = row:match('^(.-) %((⟪.*)%)%s*$')
		if not subject then subject, rest = trim(row), '' end
		local fields = {}
		for _, part in ipairs(split(rest, SEP_P)) do
			local k, v = part:match('^⟪([%w_]+)⟫: (.*)$')
			if k then fields[k] = split(v, SEP_V) end
		end
		rows[#rows + 1] = { subject = trim(subject), fields = fields }
	end
	return rows
end
p._ask = ask

local function one(fields, k) local v = fields[k]; return v and trim(v[1]) or nil end

local LOT_PRINTOUTS = {
	{ 'accession', P.lot_of }, { 'holder', P.holder }, { 'lot_role', P.lot_role },
	{ 'duplicate_of', P.duplicate_of }, { 'storage_site', P.storage_site },
	{ 'storage_country', P.storage_country }, { 'backup_distance', P.backup_distance },
	{ 'distance_measured_from', P.distance_measured_from }, { 'received_date', P.received_date, 'ISO' },
	{ 'original_kept', P.original_kept }, { 'handed_out', P.handed_out },
	{ 'backup_status', P.backup_status }, { 'lot_status', P.lot_status },
	{ 'lot_status_date', P.lot_status_date, 'ISO' }, { 'gone_reason', P.gone_reason },
}
local MULTI = { handed_out = true }

local function lotFromRow(row)
	local lot = { id = row.subject, inventory = {}, tests = {} }
	for _, po in ipairs(LOT_PRINTOUTS) do
		lot[po[1]] = MULTI[po[1]] and row.fields[po[1]] or one(row.fields, po[1])
	end
	return lot
end

local ACC_PRINTOUTS = {
	{ 'parents', P.parent }, { 'collection', P.collection }, { 'generation', P.generation },
	{ 'regrow_isolation', P.isolation }, { 'parent_females', P.females }, { 'parent_males', P.males },
	{ 'parent_total', P.total }, { 'population', P.population }, { 'copy_status', P.copy_status },
	{ 'collection_date', P.collection_date, 'ISO' }, { 'introgression_at_collection', P.introgression },
	{ 'classification', P.classification },
}

local function accFromRow(row)
	local a = { id = row.subject }
	for _, po in ipairs(ACC_PRINTOUTS) do
		if po[1] == 'parents' or po[1] == 'collection' then a[po[1]] = row.fields[po[1]] or {}
		else a[po[1]] = one(row.fields, po[1]) end
	end
	return a
end

local function loadAccessions(frame, data, cond)
	for _, row in ipairs(ask(frame, cond, ACC_PRINTOUTS)) do
		data.accessions[row.subject] = accFromRow(row)
	end
end

-- Fills in ancestors that are not yet loaded (one query per missing step, capped).
local function loadAncestors(frame, data, accId)
	local cur = accId
	for _ = 1, C.walkCap + 1 do
		if not cur then return end
		if not data.accessions[cur] then loadAccessions(frame, data, '[[' .. cur .. ']]') end
		local a = data.accessions[cur]
		if not a or #list(a.parents) ~= 1 then return end
		cur = a.parents[1]
	end
end

local function loadReports(frame, data, cond)
	local inv = ask(frame, cond .. '[[' .. P.report_date .. '::+]]', {
		{ 'lot', P.report_on }, { 'date', P.report_date, 'ISO' }, { 'quantity', P.seed_count },
		{ 'quantity_unit', P.seed_count_unit }, { 'thousand_seed_weight', P.thousand_seed_weight },
		{ 'storage', P.storage_regime }, { 'seed_moisture', P.seed_moisture } })
	for _, row in ipairs(inv) do
		local lot = data.lots[one(row.fields, 'lot') or '']
		if lot then
			local r = {}
			for k in pairs(row.fields) do r[k] = one(row.fields, k) end
			table.insert(lot.inventory, r)
		end
	end
	local tests = ask(frame, cond .. '[[' .. P.test_date .. '::+]]', {
		{ 'lot', P.report_on }, { 'date', P.test_date, 'ISO' }, { 'seeds_tested', P.seeds_tested },
		{ 'seedlings', P.seedlings }, { 'percent', P.germination_percent },
		{ 'basis', P.germination_basis }, { 'dormancy_check', P.dormancy_check },
		{ 'days_after_harvest', P.days_after_harvest } })
	for _, row in ipairs(tests) do
		local lot = data.lots[one(row.fields, 'lot') or '']
		if lot then
			local r = {}
			for k in pairs(row.fields) do r[k] = one(row.fields, k) end
			table.insert(lot.tests, r)
		end
	end
end

local function loadPopulation(frame, data, popId, asof)
	local rows = ask(frame, '[[' .. popId .. ']]', {
		{ 'band', P.field_grade }, { 'grade_year', P.grade_year }, { 'item_set', P.item_set },
		{ 'reachability', P.reachability }, { 'field_status', P.field_status } })
	local pop = { id = popId, introgression = {}, trait_changes = {} }
	if rows[1] then
		for k in pairs(rows[1].fields) do pop[k] = one(rows[1].fields, k) end
	end
	if pop.band then
		local g = p.lapse({ band = pop.band, evidenceYear = num(pop.grade_year) }, asof)
		pop.band, pop.lapsedFrom = g.band, g.lapsedFrom
	end
	for _, row in ipairs(ask(frame, '[[-Has subobject::' .. popId .. ']][[' .. P.intro_grade .. '::+]]', {
		{ 'grade', P.intro_grade }, { 'date', P.intro_year } })) do
		table.insert(pop.introgression, { grade = one(row.fields, 'grade'), date = one(row.fields, 'date') })
	end
	for _, row in ipairs(ask(frame, '[[-Has subobject::' .. popId .. ']][[' .. P.trait_date .. '::+]]', {
		{ 'date', P.trait_date, 'ISO' } })) do
		table.insert(pop.trait_changes, { date = one(row.fields, 'date') })
	end
	data.populations[popId] = pop
	-- Every accession and lot of the population, with their reports.
	loadAccessions(frame, data, '[[' .. P.population .. '::' .. popId .. ']]')
	for _, row in ipairs(ask(frame, '[[' .. P.lot_of .. '.' .. P.population .. '::' .. popId .. ']]', LOT_PRINTOUTS)) do
		data.lots[row.subject] = data.lots[row.subject] or lotFromRow(row)
	end
	loadReports(frame, data, '[[' .. P.report_on .. '.' .. P.lot_of .. '.' .. P.population .. '::' .. popId .. ']]')
	for id in pairs(data.accessions) do loadAncestors(frame, data, id) end
end

-- Data for one lot: the lot, its lineage, its population and the population's other lots.
function p.loadLot(frame, lotId, asof)
	local data = { lots = {}, accessions = {}, populations = {} }
	local rows = ask(frame, '[[' .. lotId .. ']]', LOT_PRINTOUTS)
	if not rows[1] then return data end
	local lot = lotFromRow(rows[1])
	lot.id = lotId
	data.lots[lotId] = lot
	if lot.accession then
		loadAncestors(frame, data, lot.accession)
		local a = data.accessions[lot.accession]
		if a and a.population then
			loadPopulation(frame, data, a.population, asof)
			if rows[1].subject ~= lotId then data.lots[rows[1].subject] = nil end
			data.lots[lotId] = data.lots[lotId] or lot
		end
	end
	if #data.lots[lotId].inventory == 0 and #data.lots[lotId].tests == 0 then
		loadReports(frame, data, '[[' .. P.report_on .. '::' .. lotId .. ']]')
	end
	return data
end

function p.loadPopulation(frame, popId, asof)
	local data = { lots = {}, accessions = {}, populations = {} }
	loadPopulation(frame, data, popId, asof)
	return data
end

------------------------------------------------------------------------------------------
-- Display
------------------------------------------------------------------------------------------

local function stepClass(step)
	return 'gop-step gop-step--' .. (step or 'none'):lower():gsub('%s+', '-')
end

local function asofLine(r)
	return string.format('<span class="gop-asof">as of %s · method %s</span>', r.asof, r.version)
end

function p.renderLot(r)
	if r.error then return '<span class="gop-error">Seed lot: ' .. r.error .. '</span>' end
	if r.gone then return 'Seed lot: ' .. r.goneText end
	local out = {}
	out[#out + 1] = string.format('Seed lot: <span class="%s">%s</span>', stepClass(r.step), r.step)
	if r.existsUnknown then
		out[#out + 1] = ' · not known whether it still exists (last report ' .. r.lastReport .. ')'
	end
	local why = {}
	for _, name in ipairs(r.reasons) do
		local f = r.factors[name]
		why[#why + 1] = name .. ': ' .. f.text .. (f.raised and ' (irreplaceable)' or '')
	end
	if #why > 0 then out[#out + 1] = '<br />' .. table.concat(why, '; ') end
	if r.mayBeUrgent then out[#out + 1] = '<br />replaceability unknown, may be Urgent' end
	if r.step == STEP.dd and #r.missing > 0 then
		out[#out + 1] = '<br />not known: ' .. table.concat(r.missing, ', ')
	end
	if r.actions and #r.actions > 0 then
		local a = {}
		for _, x in ipairs(r.actions) do a[#a + 1] = x.text .. (x.detail and (' (' .. x.detail .. ')') or '') end
		out[#out + 1] = '<br />Next: ' .. table.concat(a, '; ')
	end
	if r.nextTest then out[#out + 1] = '<br />Next test due ' .. r.nextTest end
	out[#out + 1] = '<br />' .. asofLine(r)
	return table.concat(out)
end

function p.renderHoldings(pr)
	local h = pr.holdings
	local out = { string.format('Holdings: <span class="%s">%s</span>', stepClass(h.step), h.step) }
	if h.why then out[#out + 1] = ' · ' .. h.why end
	out[#out + 1] = string.format('<br />%d verified %s from %d %s; places: %s',
		#h.counting, #h.counting == 1 and 'copy' or 'copies', h.nCollections,
		h.nCollections == 1 and 'collection' or 'collections', h.places or 'not known')
	for _, c in ipairs(h.listed) do
		out[#out + 1] = '<br />Every lot Urgent: collection ' .. c.id
	end
	out[#out + 1] = '<br />' .. asofLine(pr)
	return table.concat(out)
end

------------------------------------------------------------------------------------------
-- Entry points
------------------------------------------------------------------------------------------

local function args(frame)
	local a = {}
	for k, v in pairs(frame.args) do a[k] = v end
	if frame.getParent and frame:getParent() then
		for k, v in pairs(frame:getParent().args) do if blank(a[k]) then a[k] = v end end
	end
	return a
end

local function factorNames(r)
	local out = {}
	for _, name in ipairs(r.reasons or {}) do out[#out + 1] = name end
	if r.step == STEP.dd then for _, name in ipairs(r.missing or {}) do out[#out + 1] = name end end
	return out
end

-- Values to store for a lot reading (property = value or list of values).
function p.lotValues(r)
	if r.gone or r.error then return {} end
	local acts = {}
	for _, a in ipairs(r.actions or {}) do acts[#acts + 1] = a.code end
	return {
		['Has seed lot step'] = r.step,
		['Has seed lot factors'] = factorNames(r),
		['Has lot action'] = acts,
		['Is queued for sowing'] = r.queued and 'true' or 'false',
		['Is small sowing'] = r.smallSowing and 'true' or 'false',
		['Is flagged for preservation grow-out'] = r.preservation and 'true' or 'false',
		['Has method version'] = r.version,
	}
end

function p.populationValues(pr)
	local h = pr.holdings
	return {
		['Has holdings step'] = h.step,
		['Has live verified copy'] = h.liveVerifiedCopy and 'true' or 'false',
		['Has holdings rank'] = tostring(pr.ranks.holdings),
		['Has field grade rank'] = tostring(pr.ranks.field),
		['Has best lot rank'] = tostring(pr.ranks.bestLot),
		['Has method version'] = pr.version,
	}
end

local function store(frame, values)
	local props = {}
	for prop in pairs(values) do props[#props + 1] = prop end
	table.sort(props)
	local parts = {}
	for _, prop in ipairs(props) do
		local v = values[prop]
		if type(v) == 'table' then
			if #v > 0 then
				parts[#parts + 1] = prop .. '=' .. table.concat(v, ';')
				parts[#parts + 1] = '+sep=;' -- a multi-valued property needs its own separator
			end
		elseif not blank(v) then
			parts[#parts + 1] = prop .. '=' .. v
		end
	end
	return frame:preprocess('{{#set:' .. table.concat(parts, '|') .. '}}')
end

-- {{#invoke:Grow-out priority|lot|lot=<lot page or subobject>|asof=|set=yes}}
function p.lot(frame)
	local a = args(frame)
	local lotId = trim(a.lot or a[1] or mw.title.getCurrentTitle().prefixedText)
	local ctx = { today = parseDate(a.asof) }
	local data = p.loadLot(frame, lotId, fmtDate(today(ctx)))
	local r = p.evaluateLot(lotId, data, ctx)
	local out = p.renderLot(r)
	if lower(a.set) == 'yes' then out = store(frame, p.lotValues(r)) .. out end
	return out
end

-- {{#invoke:Grow-out priority|population|population=<page>|asof=|set=yes}}
function p.population(frame)
	local a = args(frame)
	local popId = trim(a.population or a[1] or mw.title.getCurrentTitle().prefixedText)
	local ctx = { today = parseDate(a.asof) }
	local data = p.loadPopulation(frame, popId, fmtDate(today(ctx)))
	local pr = p.evaluatePopulation(popId, data, ctx)
	local out = p.renderHoldings(pr)
	if lower(a.set) == 'yes' then out = store(frame, p.populationValues(pr)) .. out end
	return out
end

-- The germination count table: {{#invoke:Grow-out priority|countTable|sizes=25,50,100,200|firsts=90,95}}
function p.countTable(frame)
	local a = args(frame)
	local sizes, firsts = {}, {}
	for s in tostring(a.sizes or '25,50,100,200'):gmatch('%d+') do sizes[#sizes + 1] = tonumber(s) end
	for s in tostring(a.firsts or '90,95'):gmatch('[%d%.]+') do firsts[#firsts + 1] = tonumber(s) end
	local lines = { { q = C.floor, label = '70% floor' } }
	for _, f in ipairs(firsts) do
		local q = C.relative * f / 100
		lines[#lines + 1] = { q = q, label = string.format('85%% of a first test of %s%% (%s%%)', f, math.floor(q * 10000 + 0.5) / 100) }
	end
	local t = { '{| class="wikitable"', '! Seeds tested' }
	for _, l in ipairs(lines) do t[#t + 1] = '! ' .. l.label .. ': passes !! clearly below' end
	for _, n in ipairs(sizes) do
		t[#t + 1] = '|-'
		local row = { '| ' .. n }
		for _, l in ipairs(lines) do
			local b = p.clearlyBelowAt(n, l.q)
			row[#row + 1] = p.passesAt(n, l.q) .. ' or more || ' .. (b >= 0 and (b .. ' or fewer') or 'none')
		end
		t[#t + 1] = table.concat(row, ' || ')
	end
	t[#t + 1] = '|}'
	return table.concat(t, '\n')
end

function p.version() return p.VERSION end

return p