VDF is Vensim's proprietary, undocumented binary format for simulation output. The format preserves runtime-oriented structures: the offset table behaves like the saved runtime variable array, section-1 records behave like variable descriptors, section 2 is the string pool, section 3 is an array-shape table. This is why internal SMOOTH/DELAY helper variables appear in a VDF -- they exist in the simulation state and are saved as part of it.
Vensim can open a .vdf file and show its contents without the .mdl model,
because the file carries enough metadata to map variable names to time series.
This document describes that metadata and the deterministic procedure that
reconstructs a Results struct (variable names -> time series) from a VDF
alone.
The implementation is in src/simlin-engine/src/vdf.rs and its
submodules; tools/vdf_xray.py is a structural inspector for the same format.
The two implementations are pinned together by a differential parity harness
(parity_python_and_rust_vdf_extraction_agree in
src/simlin-engine/tests/integration/vdf_parity.rs): it walks every run-file
VDF under test/, extracts named results with both readers via the tool's
--extract-json mode, and requires identical name sets and bitwise-identical
values -- so a behavior change in one reader without the other fails CI.
Conventions. All values are little-endian. Numeric data is 32-bit floats unless noted. All offsets are byte offsets.
Container kinds. The first four bytes identify the container:
| Magic | Kind | Layout |
|---|---|---|
7f f7 17 52 |
simulation run | 8 sections (described here) |
7f f7 17 41 |
dataset / reference mode | 5 sections (see "Dataset sibling format") |
7f f7 17 53 |
sensitivity / optimization run | 8 sections plus a header[0x68]-anchored payload |
There is no Vensim version field anywhere in the file: a corpus survey across 2005-2026 found no header word or section-header field that partitions files by era. The format evolved compatibly -- 2008-era Vensim writes small integers into the section-1 pre-record header region where 2019+ writes arena-pointer-shaped values, and a reader simply tolerates whatever is there because those words are not read for decoding. The magic byte is the only structural fork.
+--------------------------------------+
| File header (168 bytes) | 0x00..0xA7
+--------------------------------------+
| Section 0: simulation command | starts at 0xA8
+--------------------------------------+
| Section 1: string table + records |
| - 12-byte preamble + 3 header blocks
| - 64-byte variable metadata records
| - slot table |
+--------------------------------------+
| Section 2: name table |
+--------------------------------------+
| Section 3: array shape directory | (zero-filled in scalar models)
+--------------------------------------+
| Section 4: view/sketch metadata |
+--------------------------------------+
| Section 5: dimension sets | (degenerate in scalar models)
+--------------------------------------+
| Section 6: OT metadata |
| - section-6 ref/record streams |
| - OT class-code array |
| - OT final-value array |
| - lookup mapping records |
+--------------------------------------+
| Section 7: lookup data + OT + blocks |
| - packed lookup f32 arrays |
| - offset table |
| - sparse data blocks |
+--------------------------------------+
168 bytes, 0x00..0xA7.
Section 0's magic bytes begin at 0xA8.
| Offset | Size | Description |
|---|---|---|
0x00 |
4 | Magic bytes (7F F7 17 52 for run files) |
0x04 |
116 | ASCII timestamp + origin, null-terminated, e.g. "(Sun Nov 30 23:28:16 2008) From bact.mdl" |
0x58 |
4 | u32 absolute file offset of the section-6 OT final-values array |
0x5C |
4 | u32 absolute file offset of the section-6 lookup mapping records (one past the final-values array) |
0x60 |
4 | u32 absolute file offset of the section-7 offset table |
0x64 |
4 | duplicate of 0x60 |
0x70 |
4 | total lookup coordinate-pair count across all graphical functions (zero when the model has none) |
0x74 |
4 | u32 data-grid time-point count: the time-point count of the external data file the run loaded (a dataset VDF or spreadsheet), or 0 when the model loaded no external data. Exogenous-data blocks carry bitmaps over THIS grid; see "Data blocks" |
0x78 |
4 | u32 saved time-point count -- the number of values stored in the Time block and returned to callers |
0x7C |
4 | u32 block time-point grid count; usually equals 0x78, but saved-suffix runs (risk.vdf) have 0x78=213, 0x7C=225 |
Derived quantities:
- OT count =
(header[0x5C] - header[0x58]) / 4 - OT class-code array starts at
header[0x58] - OT_count(one byte per OT entry) - OT final-value array starts at
header[0x58](one f32 per OT entry) - Offset table starts at
header[0x60] - First data block = the
u32atheader[0x60](OT entry 0, the Time block)
Bytes in 0x80..0xA7 are mostly zero padding plus runtime-state residue (one
word at 0x94 carries either a small integer or a RAM-pointer-shaped value and
is volatile across reruns of the same model).
The parser locates section 0 by
scanning for the section magic starting at 0x80, so this region does not
affect decoded output.
Every section is delimited by a 4-byte magic value and has a 24-byte header:
| Offset | Size | Description |
|---|---|---|
+0 |
4 | Section magic: A1 37 4C BF |
+4 |
4 | u32 field1 |
+8 |
4 | u32 field2 (equals field1 in every observed file) |
+12 |
4 | u32 field3 -- a per-section "kind" code: 135 on the name-table section and the array-directory section, 100 on the OT-metadata section, 500 everywhere else |
+16 |
4 | u32 field4 |
+20 |
4 | u32 field5 |
A section's data region runs from the end of its 24-byte header to the start of
the next section's magic; the last section runs to end-of-file.
Identify
sections by index, not by field4 -- field4 values vary across files.
field1 is a 1-based word pointer from the section magic:
section.file_offset + 4 * (field1 - 1).
For section 1 it points at the
first entry of the slot table (see "Slot table"); for section 6 it points at
the OT class-code array start (header[0x58] - OT_count); for section 7 it
points at header[0x60]; for section 5 it points at the section's final word
(region_end - 4). (In dataset files the record/header area and slot table
shift into section 0, so section 0's field1 plays section 1's role.)
| Index | Role |
|---|---|
| 0 | Simulation command string (~39-40 bytes, e.g. sim bact -I) |
| 1 | String table, variable metadata records, slot table |
| 2 | Name table |
| 3 | Array shape directory (zero-filled in scalar models) |
| 4 | View/sketch metadata |
| 5 | Dimension sets (degenerate in scalar models) |
| 6 | OT class codes, final values, lookup records, ref/record streams |
| 7 | Packed lookup data, offset table, sparse data blocks |
Section 1's data region holds three packed sub-structures.
The first 204 bytes are reserved: a 12-byte preamble followed by three 64-byte
"header blocks".
They never represent variable records.
Most of the 51 u32
words here are byte-identical across reruns of the same model; only
block0[14], block0[15], and block1[1] vary (as a deterministic
(N-1, N, N+1) triple).
Block 2 carries a float-1.0 marker at block2[9].
Three words in the preamble are stable cross-corpus invariants:
data[0..4] == 124(a base-slot offset constant;188on WRLD3-03 SCEN01.VDF)data[4..8] == OT_count - 1 - max_stock_ot_index, wheremax_stock_ot_indexis the largest OT index whose class code is0x08data[8..12] == count of section-6 lookup mapping records
Block 1 also satisfies block1[10] >> 16 == block1[11] on every observed file.
block1[7] is the slot count -- the number of slot-table entries -- on
every run-file and dataset VDF in the corpus (see "Slot table").
The first variable record starts at sec1.data_offset() + 204 and records
follow on 64-byte strides until just before the slot table.
A few files leave a
sub-64-byte trailer; it is not a record.
| Field | Name | Meaning |
|---|---|---|
| 0 | type_flags |
variable type/flags; 0 on padding records |
| 1 | classification |
semantic variable kind. 138 marks a view header (also f[0]==0); 23 a system variable. The byte structure relates to but does not directly encode stock/non-stock (use the section-6 class code at f[11] for that) |
| 2 | name_key |
direct section-2 name-table key: f[2] == (name_string_start - sec2_data_start) / 4 + 7, where name_string_start is the first character of the name (after any u16 length prefix). The first name uses key 7. This is integer pointer arithmetic, not a sort rank, so it is stable on edited and compilation-order files |
| 6 | shape_code |
5 = scalar (one OT slot); 32 = generic arrayed marker (binds when exactly one section-3 entry has a non-zero flat size); other non-zero values are section-3 shape keys; 0 = non-shape (padding, dimension anchors/elements, builtins, descriptors) |
| 8 | group_or_sentinel |
0xf6800000 on many owner/system/descriptor records; otherwise a compact record-group id (a dimension anchor and its element records share this value -- see "Section 5") |
| 9 | sentinel_b |
paired 0xf6800000 with field 8 on owner-like records |
| 10 | sort_key |
view-local case-insensitive alphabetical order key; 0 on some stock/system records; used only as a descriptor tie-break |
| 11 | ot_or_lookup_index |
union field. Owner records: the OT block start index (arrayed variables point at the first of N consecutive OT entries). Graphical-function descriptor records: a zero-based index into the section-6 lookup-record array. 0 is never an owner OT start (OT[0] is Time). Validate 1 <= f[11] < OT_count and that the OT class code at f[11] is a real-data code before treating it as an owner |
| 12 | slot_ref |
byte offset into section-1 data; groups records by view/sector |
| 14 | has_lookup_marker |
0xf6800000 when the variable is associated with a lookup table (a standalone definition or a WITH LOOKUP expression), 0 otherwise. This is not the owner/descriptor discriminator |
Fields 3, 4, 5, 7, 13, 15 are zero or per-variable values of unknown meaning.
The format does not store a tag that distinguishes a graphical-function
descriptor record (whose f[11] is a lookup-record index) from an owner
record (whose f[11] is an OT start): every field's value set on descriptor
records is a subset of the owner records' value set, and the section-6 lookup
record carries no back-pointer.
A reader that has the model knows the descriptor
set; a model-free reader recognises it from the lookup-def names (see
"Name-to-OT mapping").
Once the descriptor records are set aside, the remaining
owner spans form a non-overlapping OT partition on every file whose overlaps
the descriptor peel fully resolves -- the whole tracked corpus except the
two 2007-era SimService Base.vdf files, where some records survive the peel
still in owner-vs-owner conflict.
That residual case, and the stale-f[11]
record phenomenon behind it, is described in "Residual OT-overlap" below.
An array of N u32 values, each a byte offset into section-1 data.
The
pairing is direct: slot_table[i] belongs to names[i]. (#-signature
internal-helper names sit past the slotted prefix in the name table and have no
slot-table entry, so N <= name_count.)
The table's location is fully determined by the header -- no scan or stride heuristic is needed:
- start = section 1's
field11-based word pointer (sec1.file_offset + 4 * (field1 - 1)); - count
N=block1[7](the actively-written slot count); - the table is followed by a single
0x00430000terminator word, then the name-table section magic.
These three facts over-determine each other -- start + (N + 1) * 4 equals the
name-table section's file offset on every run-file and dataset VDF in the corpus
(138 run files + 6 datasets, zero exceptions).
A reader can take any two and
cross-check the third; vdf::slot_table_from_header reads field1 + block1[7]
and verifies the terminator and boundary. (An earlier reader scanned backward
for "the largest run of unique, in-range, 4-byte-aligned offsets"; that heuristic
under-counted on edited files whose name table contained stale entries and
over-counted by one elsewhere.
It has been replaced by the structural decode.)
The name table is a superset of the stored variables.
The section header's
field5 >> 16 gives the byte length of the first name; that first name (Time
in run files) has no length prefix.
Every subsequent entry is a u16
length-prefixed string.
A u16 value of 0 is a group separator.
Some edited
files contain length-prefixed entries whose payload is non-printable binary --
treat these as stale/deleted entries and skip exactly the declared byte count
rather than stopping the table.
A stale entry consumes no name index: the
record f[2] key formula maps a printable name's string offset to its index
among the printable entries only, so a reader that counts stale entries as
indices mislabels every name after the first stale entry.
Name categories (a classification aid, not an ownership rule -- validate a saved
series through a record's f[2]/f[11]/f[6] and the section-6 class code):
| Category | Recognition | OT relationship |
|---|---|---|
| System variables | exact match Time, INITIAL TIME, FINAL TIME, TIME STEP, SAVEPER |
Time is OT[0]; the rest have ordinary owner records |
| Model variables | slotted user names | have owner records |
| Lookup definitions | section-6 lookup records and lookupish names | may be definitions only, emitted series, or descriptors overlapping evaluated outputs |
| Internal signatures | prefix and suffix # (#SMOOTH(x,3)#, #alias>SMOOTH#, #LV1<...#) |
own real OT slots when the runtime helper is saved |
| Stdlib helpers | exact match DEL, LV1, LV2, LV3, ST, RT1, RT2, DL |
own OT slots when saved |
| Group/view markers | prefix . |
no direct OT entry |
| Unit annotations | prefix - |
no direct OT entry |
| Builtin function names | exact match SUM, MIN, step, ... |
usually no saved series |
| Metadata tags | prefix : |
no direct OT entry |
Vensim emits stdlib-call output and internal-stock names in two encodings:
| Style | Output signature | Internal stocks |
|---|---|---|
| Old | #FUNCNAME(args)# |
#LV1<FUNCNAME(args)#, #DL<...#, ... |
| New | #alias>FUNC# |
#alias>FUNC>LV1#, #alias>FUNC>DL#, ... |
The new-style form encodes the user alias directly in the prefix; the old-style
form leaves it implicit.
An output signature (the name a user alias binds to)
is recognised by a positive structural signal -- a ( for old-style, exactly
one top-level > for new-style -- which rejects non-stdlib #-bracketed
display names and the multi-> sub-part names that stateful macros like
RAMP FROM TO emit.
VdfFile::output_signatures and
VdfFile::new_style_alias_signatures expose these.
Scalar models keep section 3 as 104 zero bytes (field4 == 0).
Array models
store a 25-word zero prefix, a run of fixed-width 27-word entries, and a single
trailing zero word.
| Word(s) | Meaning |
|---|---|
| 0 | index_word: self-positional, (entry_file_offset - sec3_file_offset) / 4. The last entry has index_word == 0. Multi-shape directories (Ref.vdf) chain entries so a record's f[6] points at the previous entry's index_word and the following physical entry carries the shape |
| 1..3 | packed shape: one-dimensional entries duplicate the flattened size ([3, 3]); composite entries store [flat_size, axis_a, axis_b] ([21, 7, 3]), where flat_size == product(axis_sizes) |
| 10 | packing hint: 1 for one-dimensional entries, the trailing axis size for composite entries |
| 18..19 | one axis ref per encoded axis -- a section-1 word pointer to field[9] of the dimension-anchor record for that axis: axis_ref == 60 + 16 * k, where k is the anchor's record index. These are not slot-table refs |
| 26 | encoded axis count (1 or 2 in the validated corpus) |
The decoded shape normalizes to flat_size, axis_sizes (one per axis), and
axis_refs (one anchor pointer per axis).
The same template can be referenced
by several record f[6] values, which is why section 3 is a shape directory
rather than a per-variable save list.
Variable-length structured entries that reference section-1 slot values and
encode view/sketch (diagram) information.
Each entry is a packed count word
p, then (p >> 16) + (p & 0xffff) slot refs, then a trailing self-positional
index_word ((entry_file_offset - sec4_file_offset) / 4; the last entry's is
0, acting as a terminator).
All parsed refs resolve to in-range section-1
offsets that also appear in the slot table.
This section is view-connector
metadata; it is not a variable-owner or shape-owner directory.
Numeric
overlap between section-3 and section-4 index_word values is an arithmetic
coincidence (both encode index_word self-positionally).
In scalar models section 5 is degenerate -- the next section header starts before section 5's data offset, so it has zero region data.
In array models, section 5 holds u32 n; u32 marker; u32 refs[refs_len]
entries (marker == 0 => refs_len == n + 1; marker == 1 => refs_len == n + 2; the trailing one or two refs are axis anchors, the leading n are the
payload).
The entries start immediately at the section's data offset.
Section-5 entries pair 1:1 with record field[8] dimension-anchor groups.
Sorting the anchors by f[8] ascending produces a sequence whose cardinalities
match sec5[i].n pointwise (validated across the array corpus, including
Ref.vdf's 18 dimensions).
Root dimensions list their elements directly via record field[8] groups:
the dimension anchor carries the group's f[8] value (and usually the f[14]
sentinel; on anchors f[11] is a compact dimension id, not an OT start); each
element record has the same f[8], f[6] == 0, f[10] == 0, f[12] == 124,
and a zero-based element index in f[11].
Element records may be out of file
order, so f[11] is the ordering key.
A mixed catalog can also use a compact
late-record layout (f[12] = group id, f[15] = element index, f[6] = the
section-2 name key) -- Ref.vdf's scenario does this for two of its three
elements.
Subrange dimensions recover their elements from the parent root. A subrange's section-5 payload is a strict in-order subsequence of its parent root's payload; the positions where the subrange's refs occur in the parent's payload are the element indices into the parent's element list. The root is the dimension whose payload is not a subsequence of any other dimension's payload (when a subrange matches multiple candidates, prefer the actual root). The payload refs themselves resolve to unrelated variable slots -- the VDF uses their physical slot identity as opaque "axis-participation tokens"; only the subsequence relationship is load-bearing.
Layout, in order:
- Skip
max(0, sec6.field4 - 1)4-byte words. Almost always 0 (whenfield4 == 1); whenfield4 == 2, one section-1-descriptor-offset-shaped prefix word of unknown binding. - Leading ref stream: variable-length
u32 n_refs; u32 refs[n_refs]entries. The refs resolve to a mix of model variables, unit annotations, view markers, builtin names, system variables, and stdlib helpers -- not a clean variable save list. - Post-ref record region (empty on small/medium fixtures): a stream of
fixed-width 16-byte records.
On
Ref.vdf(226 records) these form a linked-list node pool:word[0]is runtime residue,word[1]is an OT start,word[2]an OT width,word[3]the next node's 1-based section-6 word pointer (or 0). A reader walks each lookup's input-dependency chain in O(n) from the lookup record'sword[12]. - OT class-code array:
OT_countbytes, one per OT entry. Boundary fact: this array starts at bothheader[0x58] - OT_countandsec6.file_offset + 4 * (sec6.field1 - 1). - OT final-value array:
OT_countlittle-endian f32 values (the last saved value for dynamic entries, or the constant itself for inline-constant entries). - Lookup mapping records: a stream of 13-
u32records terminated by a single zero word.
| Code | Meaning |
|---|---|
0x0f |
Time (OT[0] only) |
0x05 |
input / exogenous-data block, bitmapped over the header-0x74 data grid (which coincides with the 0x7C block grid on risk.vdf and is a genuinely third grid on the zambaqui corpus; see "Data-grid blocks") |
0x06, 0x0c |
exogenous-data variants observed on the groupon fixtures: data-grid blocks like 0x05 (unsaved ones carry a zero offset-table word and the :NA: sentinel final). Not owner codes for record mapping today, so these columns are unnamed |
0x08 |
stock-backed variable. Contiguous at OT[1..S] in small/medium fixtures; scattered across several ranges in Ref.vdf |
0x11 |
dynamic non-stock; usually a per-step data block, but inline in some array-heavy files |
0x16, 0x18 |
observed only in Ref.vdf; inline OT values |
0x17 |
constant non-stock; inline f32 in the final-value array |
Stock counts validated across the small/medium corpus (water: 1 stock / 3 dynamic / 5 const / 10 total; pop: 2/3/7/13; econ: 11/37/29/78; WRLD3: 41/174/81/297).
These describe graphical-function definitions.
Each record is 13 u32 words:
| Word | Role |
|---|---|
| 0..4 | IEEE floats: graph/rendering metadata (y-min, y-max, x-min, x-max, slope hints) |
| 5 | section-7 word offset to the start of the x-array |
| 6 | section-7 word offset to the start of the y-array |
| 7 | xy-pair-count derivative (observed {0, w8-2, w8-1, 0xffffffff}) |
| 8 | xy-pair count (word[8] == word[6] - word[5]) |
| 9 | runtime arena pointer; not a file offset |
| 10 | evaluated-output OT (the OT of a consumer of the lookup, not the lookup-def name's own record; can be shared by several lookup records) |
| 11 | output width |
| 12 | optional 1-based section-6 word pointer to the root of a post-ref dependency chain (zero when the lookup has no dependencies) |
The lookup-record array is in case-insensitive alphabetical order of the
lookup-definition names, so a descriptor record's f[11] (a zero-based index
into this array) is a direct, O(1) link to the lookup's x/y arrays and output
OT.
There is no reverse link from a lookup record to its descriptor record.
Section 7 packs three sub-structures, with no separators between lookup tables:
[section header: 16 bytes]
[field4 = first lookup x-value (f32)] <- lookup data starts here
[field5 = second lookup x-value (f32)]
[...packed lookup f32 data...]
[4-5 zero u32 padding]
[offset table]
[data blocks]
Each lookup table is [x_0..x_n, y_0..y_n] -- a contiguous f32 x-array
followed immediately by the y-array.
Tables appear in lookup-definition order
(matching the section-6 lookup-record array).
Table boundaries are inferred
from x-value monotonicity, but the section-6 lookup records' word[5..6] give
the exact x/y offsets directly.
The section header's field4/field5 double
as the first two f32 values, so sec7.data_offset() is already two words into
the lookup-data stream.
OT_count u32 entries (one per OT entry, including OT[0] = Time), starting at
header[0x60].
Each entry is either a file offset to a data block (value
>= first_data_block_offset) or an inline f32 constant (any smaller value,
reinterpreted as f32).
A raw 0 decodes as the constant 0.0 for
constant-like class codes, but a raw-0 entry with the dynamic class 0x11
and a nonzero section-6 final value is a missing/no-saved-data slot (the
variable ran -- its final value is recorded -- but Vensim's save configuration
omitted the series), decoded as all-NaN, not numeric zero.
On Ref.vdf there
are 455 such entries, carrying the :NA:-arithmetic final -1.3e33.
+0 2 u16 count (stored values)
+2 bm bitmap: one bit per time point
+2+bm count * 4 f32 values, in time order
Block 0 is the Time series, with a fully dense bitmap. The reader follows OT offsets rather than assuming the referenced blocks form a gapless stream -- files can contain padding or unreferenced bytes between blocks. A non-time block's value at a time point with a clear bit holds (zero-order hold).
The bitmap width is decoded per block against up to three grids: the
saved grid (ceil(header[0x78] / 8) bytes -- most blocks), the block grid
(ceil(header[0x7C] / 8); wider on saved-suffix files like risk.vdf), and
the data grid (ceil(header[0x74] / 8); see below).
The deterministic
discriminator is local to the block: the u16 count equals the bitmap
popcount for the correct width.
Candidates are tried in that order and the
first match wins.
The ordering is justified empirically, not geometrically.
Saved-before-block
follows the narrower-first logic (the saved width never exceeds the block
width, and a wider bitmap that also matched would be popcounting past the
real bitmap into payload bytes).
The data width, however, is usually the
NARROWEST candidate, and it still must come LAST: ordinary saved-grid blocks
dominate every file, and more than 1,100 of them across the tracked corpora
coincidentally popcount-match the narrower data width (a small count whose
set bits happen to fall in the first ceil(0x74/8) bytes), while zero
exogenous blocks match a wider distinct width.
Residual latent risk: an
exogenous block with a small count and zero-heavy leading payload bytes
could in principle popcount-match the wider saved width and silently decode
with wrong placement; no corpus file does.
The section-6 class code
(0x05/0x06/0x0c marks exogenous blocks) is available as a future
discriminator or mismatch diagnostic if such a file appears.
When NO width matches, the block is
undecodable: readers NaN-fill its series and report the OT on a per-file
diagnostic list (Rust VdfData::unreconciled_ots /
VdfFile::unreconciled_data_blocks, Python
NamedResultsDiagnostics.bitmap_unreconciled_ots) -- visibly-missing data is
strictly better than garbage decoded under an assumed width.
No tracked
corpus file has such a block.
When a block uses the larger block grid, decode the full grid and sample the
positions corresponding to the saved Time values: saved-suffix files save the
tail of the run, so the grid origin is derived from the last saved time
(origin = time[last] - (grid_count - 1) * step) and each saved time maps to
round((t - origin) / step); a non-uniform or degenerate-step time axis falls
back to identity positions, and out-of-range positions decode as NaN.
Exogenous-data blocks -- class codes 0x05 (risk/zambaqui), 0x06, and
0x0c (groupon) -- are the loaded data file's sparse blocks embedded in the
run file, bitmapped over the DATA FILE's time grid, whose point count is
header word 0x74.
The zambaqui corpus proves this three ways:
baserun.vdf'sgdp deflatorblock is byte-identical (count=26, 4-byte dense bitmapff ff ff 03, payload) to the corresponding block in the siblingData.vdfdataset, whose own time axis is 26 yearly points 1980..2005 -- matching0x74 = 26in every run of that family;- nine of the 48 "old runs" files loaded a different data file (71 yearly
points spanning the whole 1980..2050 run;
0x74 = 71, saved grid 561): the same deflator series appears there as 26 set bits at positions 0..25 of a 9-byte bitmap, and interior blocks tile exactly against the next block's offset (next - this == 2 + ceil(0x74/8) + 4*count), pinning the width. (Most old-runs files -- 31 -- are instead 28-point yearly runs with0x74 = 26; see the same-width collision below. The remaining eight sharebaserun.vdf's 71-point-run shape.); risk.vdf's mixed-width story is the same phenomenon in degenerate form: its0x74equals its0x7C(225), so the data grid coincides with the block grid there.
Same-width collision. When ceil(0x74/8) == ceil(0x78/8) with different
counts (the 31 zambaqui old-runs files with a 26-point data grid inside a
28-point yearly run: both widths are 4 bytes), the width-dedup in the
discriminator drops the Data candidate and those exogenous blocks (204
across that corpus) reconcile as Saved and decode with identity positions.
On that corpus this is EXACT -- both axes are yearly from 1980, so grid
point i IS saved point i -- and avoids the span approximation entirely,
but the behavior is a consequence of dedup-by-width, not a separately chosen
rule: a future change to the dedup would silently alter those series.
The 0x74 rule reconciles every previously-undecodable block in the tracked
corpora (122 across zambaqui 0x52/0x53 runs, 126 across the six
test/metasd/social-network-valuation groupon files, whose 6-point data
grid sits inside a 121-point monthly run).
Negative result -- the data grid's time values are not in the run file.
The run stores only the grid's point count; the grid's actual time stamps
live in the external data file (a dataset VDF's Time series or a spreadsheet
row -- groupon's is a GET XLS DATA workbook that is not part of the run).
There is no companion time block, no lookup-record linkage (no section-6
lookup record's word[10] points at these OTs), and no other header word
carrying the axis.
Mapping data-grid values onto the saved time axis is
therefore an approximation: readers assume the grid spans the saved run
uniformly, anchored at the first saved time
(step = (t_last - t_first) / (grid_count - 1)), with floor semantics
(zero-order hold).
This is exact whenever the data file's axis spans the run
horizon (the zambaqui "old runs" family -- verified against the tiled
blocks); when the data ends early (baserun.vdf's deflator, 1980..2005
inside a 1980..2050 run) the VALUES are correct but interior placement is
dilated toward the run end.
First/last placement is exact in both cases,
which is what the section-6 final-value oracle pins.
A reader with access to
the sibling data file could recover the exact axis; the model-guided mapping
work tracks that direction.
Reconstructing the result set is a single pass over the section-1 records:
-
Decoded record spans. For each record whose
f[2]resolves through the section-2 name-key formula, whosef[11]is in[1, OT_count), whosef[6]yields a structural flat span (5-> 1;32-> the sole section-3 flat size; otherwise the section-3 entry keyed byf[6]), and whose covered OT slots all carry a real-data class code (0x05/0x08/0x11/0x16/0x17/0x18), emit the span[f[11], f[11] + flat_span). (A record whosef[2]does not resolve to a parsed name is re-save cruft, not an owner -- this is what discards SCEN01's "zeroed".Supplementaryrecords andbact's zeroed records.) -
Descriptor pruning. Spans that overlap in OT space form a connected component. Within each component, peel off the graphical-function descriptor record: if exactly one candidate's name is lexically lookupish (contains the space-prefixed
lookuportable, or the phrasegraphical function-- the space prefix keeps names likestable populationfrom matching), it is the descriptor; otherwise the candidate with the highestf[10]is treated as the descriptor (this fallback fires onRef.vdf, where descriptor names likeRS N2Oare domain abbreviations). A descriptor'sf[11]is its index into the section-6 lookup-record array; its data lives there, not atf[11]as an OT start. -
Emit. The remaining owner spans plus
Timeat OT[0] are the result set. System variables (INITIAL TIME,FINAL TIME,SAVEPER,TIME STEP) are ordinary records here;#-signature internal helpers own real OT slots and are emitted under their decoded names. Within each span, an OT entry that is a file offset reads its sparse data block; an inline f32 constant fills a flat series. Multi-slot (arrayed) spans whose section-3 shape resolves through axis refs to dimension anchors with matching cardinalities get element labels (name[a],name[b], ...); otherwise elements get numeric labels (name[0],name[1], ...).
VdfFile::to_results_via_records implements this.
The "stocks-first
alphabetical" ordering visible in the OT array is a consequence of Vensim's
compiler allocation, not a rule a reader needs.
A lookup-only variable is a graphical function = a table indexed by an
explicit input (y = lookup(input)).
A bare lookup -- a table with no
call site of its own -- is not a time series, so Vensim saves no data block
for it: only a descriptor record exists, with no separate consumer-owner record.
The overlap-pruning step above never sees it (it collides with nothing), so it
would otherwise decode at its f[11]-as-OT-start ghost slot (a class-0x08
stock slot holding 0/garbage).
The reader recognises it structurally (its
ghost slots all carry the stock class code -- a lookup is never a stock -- its
f[11] is a valid lookup-record index, and the forward link
lookup_record[f[11]].word[10] is a valid owner OT, with word[11] matching the
descriptor's element count for the arrayed case) and drops it, exactly like
an overlapping descriptor (record_results::standalone_lookup_only_descriptors).
The table's values, where they matter, are carried by the consumer variables
that call it with a real input -- those are ordinary owners the reader emits
under their own names.
Two additional gates protect real stocks from a coincidental drop
(SimService/Base.vdf is the cautionary case: stocks sort first in the OT
and the lookup array is also alphabetical, so with 18 lookups a real stock's
own OT start lands below n_lookups, the ghost-stock telltale is trivially
true -- the variable really is a stock -- scalar spans have no width gate,
and an unrelated lookup's forward link can complete the physical gates;
4 of its 17 exposed stocks were silently dropped this way):
- Consumer corroboration: the forward link must be the exact start of a
different decoded span of exactly the descriptor's length.
A genuine bare
lookup's consumer is a real saved variable, so this always resolves
(10/10 on
Ref.vdf); a stock-by-coincidence points at an arbitrary OT that is usually not a span start. - Per-file coherence: if any candidate passing the physical gates fails consumer corroboration, no standalone drop happens in the file at all. A writer that emits standalone descriptors does so coherently; a single incoherent candidate marks the population as owners-by-coincidence.
No record field can substitute for these gates: the SimService
false-positive stocks carry f[14] == 0xf6800000 exactly like descriptors
(they are lookup-associated stocks), while two genuine Ref.vdf descriptors
(Ozone precursor forcings, "OC, BC, and bio aerosol forcings") carry
graph-metadata floats in f[8]/f[9]/f[14] instead of sentinels.
This is why the reader does not (and should not) reconstruct a series for a
lookup-only variable: the variable's value is lookup(input) for whatever input
the model passes, which the VDF does not store.
The forward link only points at
a consumer, and the model defines how that consumer relates to the lookup --
on Ref.vdf: an identity pass-through (Historical GDP[COP] = IF Time<=cutoff THEN Historical GDP LOOKUP(Time/One year) ELSE :NA:), a unit-scaled copy
(RS GDP = RS GDP in trillions(...) * million per trillion dollars), a fixed-time
snapshot (Forestry emissions at start year = Historical forestry LOOKUP(start year)), or one row of a wider 2-D consumer (rs_hfc125 is the HFC125 column
of RS HFC[COP, HFC type]).
Recovering the lookup variable's own series from any
of these needs the model, not the VDF. (A gf(Time) lowering for such a bare
lookup is an engine bug -- a table is not generally a function of time; see
#597.)
The descriptor peel and the standalone drop resolve every owner/descriptor
overlap on the whole tracked corpus except the two 2007-era SimService
Base.vdf files (GH #841).
On those, some decoded spans survive the peel still
claiming a shared OT slot with another, differently-named span -- an
owner-vs-owner conflict no structural signal (lexical lookup-def name, forward
link) can adjudicate.
Emitting both would let alphabetical column order
silently pick the OT owner and scatter one variable's data under another
variable's name.
Hypothesis: stale-f[11] records for unsaved variables. The evidence points
to the SimService writer emitting section-1 records for model variables that
were not saved in the run (data variables, lookup/table definitions,
supplementary vars), and those records carrying a stale f[11] -- plausibly the
variable's slot in the full runtime array, while the file's OT contains only
the saved variables.
The stale f[11]-as-OT-start spans therefore land on
arbitrary saved slots.
The observed evidence on Base.vdf:
- A wide ghost span
AGE SPECIFIC FERTILITY DISTRIBUTION FUNCTION(an unsaved lookup/table,f[11] == 18 == n_lookups, 82 elements) covers OT[18,100), overlapping ~38 real narrow owners saved in that range (c real gdp,Capital Agriculture,CO2 in Deep Ocean, ...). Because itsf[11]is not< n_lookups, it is never a peel candidate, so the peel can neither remove it nor detect that it failed. - A mirror shape at OT[174,338): the real 164-element arrayed stock
Population, saved there, sits under 36 narrow ghost claimants (unsaved tables/aux whose stalef[11]lands in the same range). - No record field separates the two populations: ghosts and real owners both
carry
f[14] == 0xf6800000.
Detection (record_results::residual_overlap_components). After the peel
and the standalone drop, the reader detects the still-conflicted spans as data:
two spans conflict when they share an OT slot and carry different names
(same-name overlap is the ordinary duplicate the per-name emitter dedup already
resolves), and a union-find groups the transitively-conflicting spans into
connected components.
Recovery (record_results::resolve_residual_components). Each component is
re-resolved from scratch, recovering the real owners and dropping only the
ghosts.
This leans on one empirical invariant: Vensim allocates OT slots in
case-insensitive alphabetical order within a run (a "run" being a contiguous
alphabetical block; run boundaries are where the sequence restarts).
Per
component:
- Un-peel. Discard the component's phase-1 overlap peels -- any peeled
descriptor that spatially overlaps a component span is given a second chance
(this recovers
c Identified Oil Reserve, wrongly f10-peeled when it collided with the wide ghost). - Lexical peel. Drop spans whose names are lookupish (
lookup,table,graphical function, ...) without thef[11] < n_lookupsgate: a lookup definition is a table, not a series, so its stalef[11]cannot forward-link. This alone resolves the scalar pairs whose ghost is a table (c total population table,Coal Fraction Discoverable Table, ...). - Ordering oracle (
residual_span_is_owner). A span is a real owner iff it sits where the alphabetical allocation would put it, judged against an anchor bracket. The nearest uncontested owners on each side are the default anchors; an inverted bracket (prev sorts after next) signals a run boundary between them, so only the more reliable prev side is tested (this is how the wide ghostAGE SPECIFIC ..., which sorts before thec *owners it covers, is dropped while every narrow owner passes). When a span is bracketed on both sides by owners already recovered from adjacent components, those win: recovered reals share the span's interleaved run, so they are the reliable same-run evidence (this is what adjudicatesindicated per capita fish demandvsChina future GDP growth rateat OT 127 -- the recoveredIndicated China GDP@123 /indicated row Coal demand@128 bracket, not the nearest uncontested ownercafe history@124, which belongs to a different run). The oracle iterates a fixpoint: a span confirmed as an owner becomes an anchor for its neighbours. - Honest-drop fallback. Any conflict the oracle cannot adjudicate (no
ordering-consistent owner) is dropped -- honest missing data over a silent
first-claim win -- and surfaced on the per-file diagnostics
(
VdfFile::residual_overlap_diagnostics/ the Python reader'sNamedResultsDiagnostics.residual_overlap), analogous toVdfData::unreconciled_ots.
The whole recovery is gated per file on the measured alphabetical
consistency of the uncontested owners (RESIDUAL_ORDERING_GATE, the fraction of
adjacent OT-sorted owner pairs that are name-ordered): a file must clear
0.95 -- an overwhelming majority -- to run the oracle.
The gate also
requires a minimum number of measured pairs (RESIDUAL_ORDERING_MIN_PAIRS, 8):
below it the ratio carries no real evidence (with fewer than two owners it is
vacuously 1.0), so the oracle abstains rather than adjudicate on nothing.
The
four probed corpus files measure 98.6-99.6% over ~840 pairs (and the two
SimService files with residual components both sit at 0.9964), comfortably above
both bars; the sub-0.95 corpus files are all tiny run files with no residual
components.
A file that does not exhibit the invariant -- or offers too few
pairs to tell -- fails the gate, the oracle abstains, and every residual span is
honest-dropped, so it is never mis-adjudicated.
On the two SimService files the
recovery is complete: every real owner is recovered (Population's 164 elements,
the oil reserve series, the four indicated/industrial owners) and nothing is
left honest-dropped, so the diagnostics come back empty.
One SMOOTH(in, t) call (= SMOOTH1) adds: +1 OT (the level, class 0x08,
inserted into the contiguous stock block); +2 records (a function-token stub
with f[6] == 0 and no OT, plus a #alias>SMOOTH# helper record with
f[6] == 5 and f[11] = the level's OT); +5 names (FUNC ×2 -- the
call-site copy and the macro-definition copy -- the two macro parameter names,
and #alias>FUNC#); +3 slots (the three slotted names).
Per-macro internal
helper-slot counts: SMOOTH1/SMOOTHI 1, SMOOTH3 4 (LV3=output, LV2, LV1,
DL), DELAY1 2, DELAY3 7, RAMP FROM TO 7, SSHAPE 2, SAMPLE UNTIL 1. A
#-signature helper record's authoritative stock/non-stock signal is the OT
class code at f[11], not its f[1] (those records carry opaque/recycled
f[0]/f[1] values).
Dataset / reference-mode files share the section framing, slot-table and record layout, and sparse-block encoding with run files, but:
- 5 sections instead of 8
- the string/record area is in section 0 and the printable name table is in section 1
- section 4 starts with a zero-terminated block-offset list, then reuses the sparse-block encoding
For data.vdf, the visible dataset series are recovered by pairing section-1
names with section-0 records sorted by (f[2], file_offset) and mapping each
record's f[11] to the section-4 block list.
These files have the same eight-section layout as run files; the ordinary
header offsets, section-6 class/final/lookup tail, offset table, and sparse
blocks parse with the same rules.
Header word 0x68 is nonzero and points past
the normal sparse-block run into an additional sensitivity payload that is not
decoded -- treat any data past the normal sparse-block run as unknown.
Both readers accept the magic and parse these files with the 0x52 rules: the
Rust reader (VDF_SENSITIVITY_FILE_MAGIC, probed as
VdfKind::SensitivityRun; VdfFile::parse treats it identically to a
simulation run, since following OT offsets ignores the undecoded tail by
construction) and the Python inspector (VDF_ALT_RESULT_MAGIC).
The zambaqui
0x53 fixtures are validated end-to-end against the section-6 final-values
oracle by sensitivity_run_files_parse_and_match_final_values_oracle in
src/simlin-engine/tests/integration/vdf_sensitivity.rs.
Two 0x53-visible
behaviors to know about: unsaved OT slots (an optimization run saves only a
subset of variables) carry class code 0, a zero offset-table word, and the
:NA: sentinel (-1.298e33) as their section-6 final value; and the zambaqui
corpus (0x52 and 0x53 alike) contains class-0x05 exogenous-data blocks stored
on the header-0x74 data grid (see "Data-grid blocks" above), fully decoded by
both readers.
A negative result, recorded because the reader's design depends on it: the
format stores no tag that distinguishes a graphical-function descriptor record
(whose f[11] is a section-6 lookup-record index) from an owner record (whose
f[11] is an OT-block start).
A field-by-field analysis across the corpus confirms this:
- no byte, bit, or
(f[0], f[1])combination separates the two populations -- every field's value set on descriptor records is a subset of the owner records' value set; f[14](the lookup-association sentinel) is not the discriminator: it marks any lookup association, soWITH LOOKUPowner records carry it just like standalone definitions (see the field table in "Section 1");- the section-6 lookup record carries no back-pointer to its descriptor record, so the association cannot be inverted from that side either.
Vensim's own reader never needs the tag: it has the compiled model and already knows which symbols are graphical-function definitions. A model-free reader must reconstruct the descriptor set, which is why the pipeline in "Name-to-OT mapping" uses:
- for overlapping spans: the lexical lookup-def-name test (contains
lookup,table, orgraphical function) with the highest-f[10]fallback for files likeRef.vdfwhose descriptor names are domain abbreviations; - for standalone descriptors: the decoded forward link plus the stock-coded ghost-slot telltale (see "Standalone graphical-function ('lookup-only') descriptors").