Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions config.nims
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ else:
--define:nimTypeNames
--define:flatty64

let bassyPath = getEnv("BASSY_PATH")
if bassyPath.len > 0:
switch("path", bassyPath)

when defined(coworld):
when defined(emscripten):
error("Coworld servers are native. Build replay viewers without -d:coworld.")
Expand Down
2 changes: 1 addition & 1 deletion coworld/dependencies.lock
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
bassy 0.1.0 https://github.com/treeform/bassy b25e0efef3fec0bd86ed3154659c0762a7158bd3
bassy 0.1.0 https://github.com/treeform/bassy 2c54d822f775ce92d8b109f2f8aedb4c4b56b29d
fixxy 0.1.0 https://github.com/treeform/fixxy 05e5446dffb70093056cebb0c57721a60deaf52a
silky 0.2.0 https://github.com/treeform/silky fb9b13910edd66cf1751056784c2f7d2932a59fc
pixie 6.1.0 https://github.com/treeform/pixie 87cecced5c4c6f311c658a5f3ca0c9b43edb6aa7
Expand Down
110 changes: 110 additions & 0 deletions docs/neural-arrays.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# BASIC data and native array arithmetic

Polyworld policies can define their own networks in an ordinary `.bas` file.
`DATA` stores read-only numeric arrays, `DIM` reserves mutable arrays, and
native functions perform the expensive loops. There is no model manifest or
fixed neural architecture. ZIP packages and external weight files are deferred.

```basic
DATA encoder AS int32 = _
2, -1, 3, _
4, 0, 1
DATA bias = 5, -2

DIM features(2)
DIM hidden(1)
features(0) = 10
features(1) = 20
features(2) = 30

linear(features, encoder, bias, hidden, 3, 2)
relu(hidden, 2)
choice = argmax(hidden, 2)
```

This computes two rows, each with three inputs. `DIM features(2)` has three
elements because BASIC bounds are inclusive. Counts passed to native functions
are lengths, not upper bounds. Operations can use a prefix of a larger array.

## Data declarations

`DATA name = value, ...` accepts numeric literals and optional `+` or `-` signs.
An underscore at the end of a line continues the declaration. Declarations
must be at the top level. A DATA array must contain at least one element.

Without `AS`, integer literals remain signed int32 and decimal literals use
Bassy's deterministic Q16.16 fixed-point representation. `AS int32` requires
exact integers; `AS fixed32` converts every literal to Q16.16. Here `fixed32`
means a signed 32-bit fixed-point value with 16 fractional bits. Numeric
execution uses only int32 and Q16.16. Decimal literals are parsed directly into
fixed-point values, and the language has no floating-point type or conversion
API. The native neural operations preserve ordinary BASIC's int32 wraparound
and fixed-point rounding.

`weights(0)` reads an element. A bare `weights`, or `weights()`, supplies a
program-local array handle to a host function. Handles are checked against the
current VM; they never refer to host files, pointers, or another player's data.
String arrays cannot be passed to numeric operations.

DATA values initialize when the VM is created and are restored by `reset`.
Per-decision `restart` retains them along with ordinary arrays and globals.
Writes to DATA fail in BASIC and through native destination arguments.

## Operations and charging

| Function | Meaning | Native operations charged |
| --- | --- | --- |
| `linear(x, weights, bias, output, inputs, outputs)` | Bias plus the ordered dot product for each row | `2 * inputs * outputs + 2 * outputs` |
| `relu(values, count)` | Replace negatives with zero in place | `2 * count` |
| `argmax(values, count)` | Index of the greatest value; first index wins ties | `count` |
| `argmaxMasked(values, mask, count)` | Greatest eligible value, or `-1` when every mask entry is zero | `2 * count` |
| `dataAdd(left, right, output, count)` | Elementwise sum | `count` |
| `dataMultiply(left, right, output, count)` | Elementwise product | `count` |
| `dataCopy(source, output, count)` | Copy a numeric prefix | `count` |
| `dataFill(output, value, count)` | Fill a numeric prefix | `count` |
| `dataDot(left, right, count)` | Ordered dot product starting at zero | `2 * count` |

Each call also pays the existing VM instruction and host-entry work charges.
Native costs are deducted from **both** remaining instructions and work units,
once before the kernel executes. Size calculations use checked bounds and
int64 arithmetic. Invalid dimensions, too-short arrays, read-only destinations,
or an insufficient budget raise `BasicError` before any kernel output changes.
A later error elsewhere in BASIC does not roll back earlier completed calls.
The counts are deterministic work units, not elapsed CPU time.

Weights are row-major: `weights(row * inputs + column)`. `linear` starts with
the bias and adds products from left to right. It does not fuse, reorder,
parallelize, or convert arithmetic to floats. Its output must be a separate
array from its inputs, weights, and biases. Elementwise operations support
in-place use of the same array.

Arrays allocate when the runtime is constructed. Native kernels allocate no
tensor buffers or scratch arrays. DATA shares the existing array count and
element limits; its stored initializer and live VM copy both count toward the
logical memory allowance. Cells use Bassy's tagged numeric representation,
budgeted at 16 bytes per cell, rather than packed four-byte weight storage.
Source bytes and compiled instruction counts retain their separate limits.

GotA's existing limits remain 64 KiB of source, 32 arrays, 4,096 total array
elements, 2 MiB of logical runtime memory, 20,000 instructions and 50,000 work
units per decision.

## Implementation and dependency

Bassy implements DATA parsing, initialization, checked array views, and
`ContextHostProc` callbacks. Polyworld's `src/polyworld/neural.nim` implements
the arithmetic and registers it in all five BASIC game hosts. Game observations
and commands keep their existing APIs.

The required Bassy revision is pinned in both `nimby.lock` and
`coworld/dependencies.lock`. Install the locked dependencies before building.
For development against a sibling Bassy checkout, the optional `BASSY_PATH`
override selects its source directory:

```sh
export BASSY_PATH=../bassy-nn/src
nim check tests/tests.nim
nim r tests/tests.nim
```

The examples and test fixtures use hand-written synthetic values.
2 changes: 2 additions & 0 deletions examples/awm/awmbots.nim
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Each invocation plays at most one card; the game loop calls repeatedly
## until the bot ends its turn.
import bassy
import polyworld/neural
import awmsim

type
Expand Down Expand Up @@ -56,6 +57,7 @@ proc botLimits(): Limits =

proc buildBotHost(playerId: int32): Host =
result = initHost()
result.addNeuralFunctions()
for name in DataSlotNames:
discard result.addData(name)

Expand Down
2 changes: 2 additions & 0 deletions examples/call_to_adventure/bots.nim
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
## cannot write world fields directly.

import
polyworld/neural,
bassy,
polyworld/[bodies, metrics, cli, controllers, pathing, profiles],
content,
Expand Down Expand Up @@ -114,6 +115,7 @@ proc heroLimits(): Limits =
proc buildHeroHost(heroId: int32): Host =
## Builds the world-query and high-level action API for one hero.
result = initHost()
result.addNeuralFunctions()
for name in HeroDataNames:
discard result.addData(name)

Expand Down
2 changes: 2 additions & 0 deletions examples/gods_of_the_arena/bots.nim
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
## on the simulation.

import
polyworld/neural,
bassy, fixxy,
polyworld/[metrics, bodies, cli, controllers, pathing, profiles, tapes],
content,
Expand Down Expand Up @@ -262,6 +263,7 @@ proc abilityProc(heroId: int32, field: AbilityField): HostProc =
proc initHeroHost(heroId: int32): Host =
## Builds the bounded world-query and action interface for one hero.
result = initHost()
result.addNeuralFunctions()
for error in ActionError:
discard result.addData($error, error.ord.int32)
for class in HeroClass:
Expand Down
2 changes: 2 additions & 0 deletions examples/heartleaf/bots.nim
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
## commands only queue orders.

import
polyworld/neural,
bassy,
polyworld/[bodies, profiles],
content,
Expand Down Expand Up @@ -137,6 +138,7 @@ proc buildVillagerHost*(slot: int32): Host =
## live instance, because `initRuntime` validates every binding's arity
## and work cost against what the program was compiled with.
result = initHost()
result.addNeuralFunctions()
for name in VillagerDataNames:
discard result.addData(name)

Expand Down
2 changes: 2 additions & 0 deletions examples/light_vs_dark/bots.nim
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
## kill things, and it is where fog of war is applied.

import
polyworld/neural,
bassy,
polyworld/[bodies, metrics, profiles],
content,
Expand Down Expand Up @@ -280,6 +281,7 @@ proc buildOverlordHost*(playerId: int32): Host =
## cost far more than their own cycles, so a script's budget prices its
## demand on the simulation rather than only its own arithmetic.
result = initHost()
result.addNeuralFunctions()
for name in OverlordDataNames:
discard result.addData(name)

Expand Down
2 changes: 1 addition & 1 deletion nimby.lock
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
bassy 0.1.0 https://github.com/treeform/bassy b25e0efef3fec0bd86ed3154659c0762a7158bd3
bassy 0.1.0 https://github.com/treeform/bassy 2c54d822f775ce92d8b109f2f8aedb4c4b56b29d
fixxy 0.1.0 https://github.com/treeform/fixxy 05e5446dffb70093056cebb0c57721a60deaf52a
silky 0.2.0 https://github.com/treeform/silky fb9b13910edd66cf1751056784c2f7d2932a59fc
pixie 6.1.0 https://github.com/treeform/pixie 87cecced5c4c6f311c658a5f3ca0c9b43edb6aa7
Expand Down
166 changes: 166 additions & 0 deletions src/polyworld/neural.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
## Deterministic native neural operations for every Polyworld BASIC host.

import bassy

proc count(value: Value): int =
## Accepts a positive, exact element count without narrowing first.
let size = value.asInt
if size <= 0:
raise newException(BasicError, "array count must be positive")
int(size)

proc requireLength(values: ArrayView, size: int) =
## Checks a prefix before any kernel runs or output changes.
if size > values.len:
raise newException(BasicError, "array is smaller than the requested shape")

proc linear(runtime: Runtime, arguments: openArray[Value]): Value =
## Evaluates bias-first, row-major affine rows in BASIC arithmetic order.
let
inputs = runtime.arrayView(arguments[0])
weights = runtime.arrayView(arguments[1])
biases = runtime.arrayView(arguments[2])
outputs = runtime.arrayView(arguments[3], writable = true)
width = count(arguments[4])
height = count(arguments[5])
cells = int64(width) * int64(height)
inputs.requireLength(width)
biases.requireLength(height)
outputs.requireLength(height)
if cells > int64(weights.len):
raise newException(BasicError, "linear weights do not fit the shape")
if outputs.overlaps(inputs) or outputs.overlaps(weights) or
outputs.overlaps(biases):
raise newException(BasicError, "linear output must use separate storage")
runtime.chargeOperations(2 * cells + 2 * int64(height))
for row in 0 ..< height:
var total = biases[row]
for column in 0 ..< width:
total = total + inputs[column] * weights[row * width + column]
outputs[row] = total
toValue(0)

proc relu(runtime: Runtime, arguments: openArray[Value]): Value =
## Replaces negative values in a mutable prefix with integer zero.
let
values = runtime.arrayView(arguments[0], writable = true)
size = count(arguments[1])
values.requireLength(size)
runtime.chargeOperations(2 * int64(size))
for i in 0 ..< size:
if values[i] < toValue(0):
values[i] = toValue(0)
toValue(0)

proc argmax(runtime: Runtime, arguments: openArray[Value]): Value =
## Returns the first index with the greatest value in a numeric prefix.
let
values = runtime.arrayView(arguments[0])
size = count(arguments[1])
values.requireLength(size)
runtime.chargeOperations(int64(size))
var best = 0
for i in 1 ..< size:
if values[best] < values[i]:
best = i
toValue(best)

proc argmaxMasked(runtime: Runtime, arguments: openArray[Value]): Value =
## Returns the first greatest eligible index, or -1 for an empty mask.
let
values = runtime.arrayView(arguments[0])
mask = runtime.arrayView(arguments[1])
size = count(arguments[2])
values.requireLength(size)
mask.requireLength(size)
runtime.chargeOperations(2 * int64(size))
var best = -1
for i in 0 ..< size:
if not mask[i].asBool:
continue
if best < 0 or values[best] < values[i]:
best = i
toValue(best)

proc dataAdd(runtime: Runtime, arguments: openArray[Value]): Value =
## Adds two numeric prefixes after reserving their work.
let
size = count(arguments[3])
left = runtime.arrayView(arguments[0])
right = runtime.arrayView(arguments[1])
outputs = runtime.arrayView(arguments[2], writable = true)
left.requireLength(size)
right.requireLength(size)
outputs.requireLength(size)
runtime.chargeOperations(int64(size))
for i in 0 ..< size:
outputs[i] = left[i] + right[i]
toValue(0)

proc dataMultiply(runtime: Runtime, arguments: openArray[Value]): Value =
## Multiplies two numeric prefixes after reserving their work.
let
size = count(arguments[3])
left = runtime.arrayView(arguments[0])
right = runtime.arrayView(arguments[1])
outputs = runtime.arrayView(arguments[2], writable = true)
left.requireLength(size)
right.requireLength(size)
outputs.requireLength(size)
runtime.chargeOperations(int64(size))
for i in 0 ..< size:
outputs[i] = left[i] * right[i]
toValue(0)

proc dataCopy(runtime: Runtime, arguments: openArray[Value]): Value =
## Copies a numeric prefix after reserving its work.
let
size = count(arguments[2])
source = runtime.arrayView(arguments[0])
outputs = runtime.arrayView(arguments[1], writable = true)
source.requireLength(size)
outputs.requireLength(size)
runtime.chargeOperations(int64(size))
for i in 0 ..< size:
outputs[i] = source[i]
toValue(0)

proc dataFill(runtime: Runtime, arguments: openArray[Value]): Value =
## Fills a mutable numeric prefix after reserving its work.
let
size = count(arguments[2])
outputs = runtime.arrayView(arguments[0], writable = true)
value = arguments[1]
outputs.requireLength(size)
if value.kind == StringValue:
raise newException(BasicError, "dataFill requires a number")
runtime.chargeOperations(int64(size))
for i in 0 ..< size:
outputs[i] = value
toValue(0)

proc dataDot(runtime: Runtime, arguments: openArray[Value]): Value =
## Computes an ordered dot product after reserving its work.
let
size = count(arguments[2])
left = runtime.arrayView(arguments[0])
right = runtime.arrayView(arguments[1])
left.requireLength(size)
right.requireLength(size)
runtime.chargeOperations(2 * int64(size))
var total = toValue(0)
for i in 0 ..< size:
total = total + left[i] * right[i]
total

proc addNeuralFunctions*(host: var Host) =
## Registers generic numeric operations without prescribing a network.
discard host.addFunction("linear", 6, linear)
discard host.addFunction("relu", 2, relu)
discard host.addFunction("argmax", 2, argmax)
discard host.addFunction("argmaxMasked", 3, argmaxMasked)
discard host.addFunction("dataAdd", 4, dataAdd)
discard host.addFunction("dataMultiply", 4, dataMultiply)
discard host.addFunction("dataCopy", 3, dataCopy)
discard host.addFunction("dataFill", 3, dataFill)
discard host.addFunction("dataDot", 3, dataDot)
Loading
Loading