feat(landscape): computation graph, params and per-pixel kernels

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 13:16:17 +05:00
parent 922812b765
commit 831ddd1154
6 changed files with 761 additions and 0 deletions

153
internal/landscape/graph.go Normal file
View File

@@ -0,0 +1,153 @@
// Package landscape defines the Landscape Program (ЛП) computation graph — the
// per-pixel formulas that turn ecological (Z), vulnerability (U) and
// suitability (P) class rasters into constraints (E), conflicts (C) and
// functional zoning (V1–V3) — together with a window-based executor.
package landscape
import "sort"
const (
// NoData is the output nodata value of every LP raster.
NoData int16 = -9999
// CategoryCode is the dataset category holding every LP dataset.
CategoryCode = "landscape"
)
// Group codes. Slots are leaf inputs users upload; the rest are node outputs.
const (
SlotZBiodiversity = "lp-z-biodiversity"
SlotZLCR = "lp-z-lcr"
SlotZSoilProtection = "lp-z-soil-protection"
SlotZWR = "lp-z-wr"
SlotUSurfWater = "lp-u-surf-water"
SlotUSoil = "lp-u-soil"
SlotUCrop = "lp-u-crop"
SlotUVeg = "lp-u-veg"
SlotPIrrigation = "lp-p-irrigation"
SlotPSnow = "lp-p-snow"
SlotPCrop = "lp-p-crop"
SlotPPasture = "lp-p-pasture"
SlotPTourism = "lp-p-tourism"
OutZIntegrated = "lp-z-integrated"
OutUIntegrated = "lp-u-integrated"
OutZMax = "lp-z-max"
OutUMax = "lp-u-max"
OutE = "lp-e"
OutCIrrigation = "lp-c-irrigation"
OutCSnow = "lp-c-snow"
OutCCrop = "lp-c-crop"
OutCPasture = "lp-c-pasture"
OutCTourism = "lp-c-tourism"
OutPMax = "lp-p-max"
OutV1 = "lp-v1-conservative"
OutV2 = "lp-v2-balanced"
OutV3 = "lp-v3-development"
OutCMax = "lp-c-max"
)
type Slot struct{ Code, Title string }
type Output struct{ Code, Title string }
// Kernel computes one window. in[i] follows Node.Inputs order and out[j]
// follows Node.Outputs order; every slice has exactly window-pixel length.
type Kernel func(in [][]float64, out [][]int16, p Params)
// Node is one function of the graph. Stage = first digit of Code.
type Node struct {
Code, Name string
Stage int
Inputs []string
Outputs []string
Kernel Kernel
}
var Slots = []Slot{
{SlotZBiodiversity, "Z Biodiversity"}, {SlotZLCR, "Z LCR"}, {SlotZSoilProtection, "Z Soil Protection"}, {SlotZWR, "Z Water Resources"},
{SlotUSurfWater, "U Surface Water"}, {SlotUSoil, "U Soil"}, {SlotUCrop, "U Crop"}, {SlotUVeg, "U Vegetation"},
{SlotPIrrigation, "P Irrigation"}, {SlotPSnow, "P Snow"}, {SlotPCrop, "P Crop"}, {SlotPPasture, "P Pasture"}, {SlotPTourism, "P Tourism"},
}
var Outputs = []Output{
{OutZIntegrated, "Z Integrated"}, {OutUIntegrated, "U Integrated"}, {OutZMax, "Z Max"}, {OutUMax, "U Max"},
{OutE, "E Ecological Constraint"},
{OutCIrrigation, "C Irrigation"}, {OutCSnow, "C Snow"}, {OutCCrop, "C Crop"}, {OutCPasture, "C Pasture"}, {OutCTourism, "C Tourism"},
{OutPMax, "P Max"},
{OutV1, "V1 Conservative"}, {OutV2, "V2 Balanced"}, {OutV3, "V3 Development"}, {OutCMax, "C Max"},
}
var zInputs = []string{SlotZBiodiversity, SlotZLCR, SlotZSoilProtection, SlotZWR}
var uInputs = []string{SlotUSurfWater, SlotUSoil, SlotUCrop, SlotUVeg} // order matters: water, soil, crop, veg
var pInputs = []string{SlotPIrrigation, SlotPSnow, SlotPCrop, SlotPPasture, SlotPTourism} // sector order 1..5
var cOutputs = []string{OutCIrrigation, OutCSnow, OutCCrop, OutCPasture, OutCTourism}
var Nodes = []Node{
{Code: "00", Name: "z_integrated", Stage: 0, Inputs: zInputs, Outputs: []string{OutZIntegrated}, Kernel: kernelZIntegrated},
{Code: "01", Name: "u_integrated", Stage: 0, Inputs: uInputs, Outputs: []string{OutUIntegrated}, Kernel: kernelUIntegrated},
{Code: "02", Name: "z_max", Stage: 0, Inputs: zInputs, Outputs: []string{OutZMax}, Kernel: kernelZMax},
{Code: "03", Name: "u_max", Stage: 0, Inputs: uInputs, Outputs: []string{OutUMax}, Kernel: kernelUMax},
{Code: "10", Name: "e_ecological_constraint", Stage: 1, Inputs: []string{OutZIntegrated, OutUIntegrated}, Outputs: []string{OutE}, Kernel: kernelE},
{Code: "20", Name: "c_conflicts", Stage: 2, Inputs: append(append([]string{}, pInputs...), OutE), Outputs: cOutputs, Kernel: kernelCConflicts},
{Code: "21", Name: "p_max", Stage: 2, Inputs: pInputs, Outputs: []string{OutPMax}, Kernel: kernelMaxAnyValid},
{Code: "30", Name: "func_zoning", Stage: 3, Inputs: append([]string{OutE}, cOutputs...), Outputs: []string{OutV1, OutV2, OutV3}, Kernel: kernelFuncZoning},
{Code: "31", Name: "c_max", Stage: 3, Inputs: cOutputs, Outputs: []string{OutCMax}, Kernel: kernelMaxAnyValid},
}
func NodeByCode(code string) (Node, bool) {
for _, n := range Nodes {
if n.Code == code {
return n, true
}
}
return Node{}, false
}
func SlotByCode(code string) (Slot, bool) {
for _, s := range Slots {
if s.Code == code {
return s, true
}
}
return Slot{}, false
}
func OutputByCode(code string) (Output, bool) {
for _, o := range Outputs {
if o.Code == code {
return o, true
}
}
return Output{}, false
}
// NodeForOutput returns the node producing the given output group.
func NodeForOutput(group string) (Node, bool) {
for _, n := range Nodes {
for _, o := range n.Outputs {
if o == group {
return n, true
}
}
}
return Node{}, false
}
// Stages groups nodes by stage, ascending; within a stage nodes are sorted by code.
func Stages() [][]Node {
byStage := map[int][]Node{}
for _, n := range Nodes {
byStage[n.Stage] = append(byStage[n.Stage], n)
}
keys := make([]int, 0, len(byStage))
for k := range byStage {
keys = append(keys, k)
}
sort.Ints(keys)
out := make([][]Node, 0, len(keys))
for _, k := range keys {
ns := byStage[k]
sort.Slice(ns, func(i, j int) bool { return ns[i].Code < ns[j].Code })
out = append(out, ns)
}
return out
}

View File

@@ -0,0 +1,134 @@
package landscape
import (
"regexp"
"testing"
)
var slugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
// isSlot reports whether code names one of the leaf input slots.
func isSlot(code string) bool {
_, ok := SlotByCode(code)
return ok
}
func TestNodeInputsComeFromSlotsOrLowerStage(t *testing.T) {
for _, n := range Nodes {
for _, in := range n.Inputs {
if isSlot(in) {
continue
}
producer, ok := NodeForOutput(in)
if !ok {
t.Errorf("node %s: input %q is neither a slot nor a known node output", n.Code, in)
continue
}
if producer.Stage >= n.Stage {
t.Errorf("node %s (stage %d): input %q is produced by node %s at stage %d, want stage < %d",
n.Code, n.Stage, in, producer.Code, producer.Stage, n.Stage)
}
}
}
}
func TestEveryOutputHasExactlyOneProducer(t *testing.T) {
count := map[string]int{}
for _, n := range Nodes {
for _, o := range n.Outputs {
count[o]++
}
}
for _, o := range Outputs {
if c := count[o.Code]; c != 1 {
t.Errorf("output %q has %d producers, want exactly 1", o.Code, c)
}
}
// No node should produce something that isn't in the Outputs list.
for code, c := range count {
if _, ok := OutputByCode(code); !ok {
t.Errorf("node output %q (%d producers) is not declared in Outputs", code, c)
}
}
}
func TestStagesGroupingAndOrder(t *testing.T) {
stages := Stages()
if len(stages) != 4 {
t.Fatalf("got %d stages, want 4", len(stages))
}
want := [][]string{
{"00", "01", "02", "03"},
{"10"},
{"20", "21"},
{"30", "31"},
}
for i, group := range stages {
var codes []string
for _, n := range group {
codes = append(codes, n.Code)
}
if len(codes) != len(want[i]) {
t.Fatalf("stage group %d: got codes %v, want %v", i, codes, want[i])
}
for j, c := range codes {
if c != want[i][j] {
t.Errorf("stage group %d: got codes %v, want %v", i, codes, want[i])
break
}
}
}
}
func TestAllGroupCodesAreValidSlugs(t *testing.T) {
for _, s := range Slots {
if !slugRe.MatchString(s.Code) {
t.Errorf("slot code %q is not a valid slug", s.Code)
}
}
for _, o := range Outputs {
if !slugRe.MatchString(o.Code) {
t.Errorf("output code %q is not a valid slug", o.Code)
}
}
}
func TestNodeByCode(t *testing.T) {
n, ok := NodeByCode("00")
if !ok || n.Name != "z_integrated" {
t.Fatalf("NodeByCode(00) = %+v, %v", n, ok)
}
if _, ok := NodeByCode("99"); ok {
t.Fatal("NodeByCode(99) should not be found")
}
}
func TestSlotByCode(t *testing.T) {
s, ok := SlotByCode(SlotZBiodiversity)
if !ok || s.Title != "Z Biodiversity" {
t.Fatalf("SlotByCode(%s) = %+v, %v", SlotZBiodiversity, s, ok)
}
if _, ok := SlotByCode("nope"); ok {
t.Fatal("SlotByCode(nope) should not be found")
}
}
func TestOutputByCode(t *testing.T) {
o, ok := OutputByCode(OutE)
if !ok || o.Title != "E Ecological Constraint" {
t.Fatalf("OutputByCode(%s) = %+v, %v", OutE, o, ok)
}
if _, ok := OutputByCode("nope"); ok {
t.Fatal("OutputByCode(nope) should not be found")
}
}
func TestNodeForOutput(t *testing.T) {
n, ok := NodeForOutput(OutV1)
if !ok || n.Code != "30" {
t.Fatalf("NodeForOutput(%s) = %+v, %v", OutV1, n, ok)
}
if _, ok := NodeForOutput("nope"); ok {
t.Fatal("NodeForOutput(nope) should not be found")
}
}

View File

@@ -0,0 +1,212 @@
package landscape
// valid reports whether a class value is in the analysis domain (1..5). Any
// other value, including nodata and NaN, is invalid.
func valid(v float64) bool { return v >= 1 && v <= 5 }
// classify maps a float32 score onto classes 1..5 with the fixed intervals
// used by the reference notebook (comparisons in float32, like numpy on a
// float32 array). Scores outside [1, 5] yield NoData.
func classify(x float32) int16 {
switch {
case x < 1.0:
return NoData
case x <= 1.8:
return 1
case x <= 2.6:
return 2
case x <= 3.4:
return 3
case x <= 4.2:
return 4
case x <= 5.0:
return 5
default:
return NoData
}
}
// kernelZIntegrated: all four Z valid -> classify(0.25*Z1 + 0.25*Z2 + 0.25*Z3 + 0.25*Z4).
func kernelZIntegrated(in [][]float64, out [][]int16, _ Params) {
for i := range out[0] {
z1, z2, z3, z4 := in[0][i], in[1][i], in[2][i], in[3][i]
if !(valid(z1) && valid(z2) && valid(z3) && valid(z4)) {
out[0][i] = NoData
continue
}
score := float32(0.25)*float32(z1) + float32(0.25)*float32(z2) + float32(0.25)*float32(z3) + float32(0.25)*float32(z4)
out[0][i] = classify(score)
}
}
// kernelUIntegrated: water and soil required, at least one of crop/veg;
// U = (0.25*water + 0.25*soil + [0.25*crop] + [0.25*veg]) / (0.5 + [0.25] + [0.25]).
func kernelUIntegrated(in [][]float64, out [][]int16, _ Params) {
for i := range out[0] {
water, soil, crop, veg := in[0][i], in[1][i], in[2][i], in[3][i]
cropOK, vegOK := valid(crop), valid(veg)
if !(valid(water) && valid(soil) && (cropOK || vegOK)) {
out[0][i] = NoData
continue
}
num := float32(0.25)*float32(water) + float32(0.25)*float32(soil)
den := float32(0.5)
if cropOK {
num += float32(0.25) * float32(crop)
den += 0.25
}
if vegOK {
num += float32(0.25) * float32(veg)
den += 0.25
}
out[0][i] = classify(num / den)
}
}
// kernelZMax: all four valid -> max.
func kernelZMax(in [][]float64, out [][]int16, _ Params) {
for i := range out[0] {
m, ok := int16(0), true
for _, layer := range in {
v := layer[i]
if !valid(v) {
ok = false
break
}
if int16(v) > m {
m = int16(v)
}
}
if !ok {
m = NoData
}
out[0][i] = m
}
}
// kernelUMax: water and soil required plus one of crop/veg; max over valid layers.
func kernelUMax(in [][]float64, out [][]int16, _ Params) {
for i := range out[0] {
water, soil, crop, veg := in[0][i], in[1][i], in[2][i], in[3][i]
cropOK, vegOK := valid(crop), valid(veg)
if !(valid(water) && valid(soil) && (cropOK || vegOK)) {
out[0][i] = NoData
continue
}
m := max(int16(water), int16(soil))
if cropOK {
m = max(m, int16(crop))
}
if vegOK {
m = max(m, int16(veg))
}
out[0][i] = m
}
}
// kernelE: Z and U valid -> max(Z, U).
func kernelE(in [][]float64, out [][]int16, _ Params) {
for i := range out[0] {
z, u := in[0][i], in[1][i]
if !(valid(z) && valid(u)) {
out[0][i] = NoData
continue
}
out[0][i] = max(int16(z), int16(u))
}
}
// kernelCConflicts: inputs P1..P5, E; output j = min(Pj, E) where both valid.
func kernelCConflicts(in [][]float64, out [][]int16, _ Params) {
e := in[5]
for j := 0; j < 5; j++ {
p := in[j]
for i := range out[j] {
if !(valid(p[i]) && valid(e[i])) {
out[j][i] = NoData
continue
}
out[j][i] = min(int16(p[i]), int16(e[i]))
}
}
}
// kernelMaxAnyValid: max over the valid inputs; NoData when none is valid.
// Used for P_max and C_max.
func kernelMaxAnyValid(in [][]float64, out [][]int16, _ Params) {
for i := range out[0] {
m, any := int16(0), false
for _, layer := range in {
if v := layer[i]; valid(v) {
any = true
m = max(m, int16(v))
}
}
if !any {
m = NoData
}
out[0][i] = m
}
}
// kernelFuncZoning implements TZ_functional_zoning_go.md §4. Inputs: E, then
// S1..S5 (irrigation, snow, crop, pasture, tourism). Outputs: V1, V2, V3.
func kernelFuncZoning(in [][]float64, out [][]int16, p Params) {
e := in[0]
rules := [3]func(e, s int16) bool{
func(e, s int16) bool {
return (e <= 2 && s >= int16(p.V1MinSE12)) || (e == 3 && s >= int16(p.V1MinSE3))
},
func(e, s int16) bool {
return e <= 2 || (e == 3 && s >= int16(p.V2MinSE3)) || (e == 4 && s >= int16(p.V2MinSE4))
},
func(e, s int16) bool { return e <= 4 },
}
var s [5]int16
var base [5]bool
for i := range out[0] {
if !valid(e[i]) {
out[0][i], out[1][i], out[2][i] = NoData, NoData, NoData
continue
}
ev := int16(e[i])
anyBase := false
for k := 0; k < 5; k++ {
base[k] = valid(in[k+1][i])
if base[k] {
s[k] = int16(in[k+1][i])
anyBase = true
}
}
if !anyBase {
out[0][i], out[1][i], out[2][i] = NoData, NoData, NoData
continue
}
for v := 0; v < 3; v++ {
var cand [5]bool
maxS, anyCand := int16(0), false
for k := 0; k < 5; k++ {
cand[k] = base[k] && rules[v](ev, s[k])
if cand[k] {
anyCand = true
if s[k] > maxS {
maxS = s[k]
}
}
}
if !anyCand {
out[v][i] = 1 // regulated
continue
}
sector := int16(0)
for _, code := range p.TiePriority {
k := code - 1
if cand[k] && s[k] == maxS {
sector = int16(code)
break
}
}
out[v][i] = sector + 1
}
}
}

View File

@@ -0,0 +1,123 @@
package landscape
import (
"reflect"
"testing"
)
func run(k Kernel, nOut int, in ...[]float64) [][]int16 {
out := make([][]int16, nOut)
for j := range out {
out[j] = make([]int16, len(in[0]))
}
k(in, out, DefaultParams())
return out
}
func TestClassify(t *testing.T) {
cases := map[float32]int16{1.0: 1, 1.8: 1, 1.81: 2, 2.6: 2, 2.75: 3, 3.4: 3, 3.5: 4, 4.2: 4, 4.25: 5, 5.0: 5}
for x, want := range cases {
if got := classify(x); got != want {
t.Errorf("classify(%v)=%d want %d", x, got, want)
}
}
if classify(0.9) != NoData || classify(5.1) != NoData {
t.Error("out of range must be NoData")
}
}
func TestKernelZIntegrated(t *testing.T) {
// pixel0: all valid (1,2,3,4) -> 2.5 -> class 2 ; pixel1: one nodata -> NoData ; pixel2: all 5 -> 5 -> class 5
got := run(kernelZIntegrated, 1,
[]float64{1, -9999, 5}, []float64{2, 2, 5}, []float64{3, 3, 5}, []float64{4, 4, 5})
if want := []int16{2, NoData, 5}; !reflect.DeepEqual(got[0], want) {
t.Fatalf("got %v want %v", got[0], want)
}
}
func TestKernelUIntegrated_Applicability(t *testing.T) {
// order: water, soil, crop, veg
// p0: all valid 1,1,5,5 -> (0.25+0.25+1.25+1.25)/1.0 = 3.0 -> class 3
// p1: crop invalid: 1,1,-,5 -> (0.25+0.25+1.25)/0.75 = 2.333 -> class 2
// p2: crop and veg invalid -> NoData
// p3: water invalid -> NoData
got := run(kernelUIntegrated, 1,
[]float64{1, 1, 1, -9999}, []float64{1, 1, 1, 1}, []float64{5, 0, 0, 5}, []float64{5, 5, 0, 5})
if want := []int16{3, 2, NoData, NoData}; !reflect.DeepEqual(got[0], want) {
t.Fatalf("got %v want %v", got[0], want)
}
}
func TestKernelZMax_RequiresAllValid(t *testing.T) {
got := run(kernelZMax, 1, []float64{1, 1}, []float64{4, 4}, []float64{2, -9999}, []float64{3, 3})
if want := []int16{4, NoData}; !reflect.DeepEqual(got[0], want) {
t.Fatalf("got %v want %v", got[0], want)
}
}
func TestKernelUMax_IgnoresInvalidCropVeg(t *testing.T) {
// water, soil, crop, veg ; p0: 1,2,-,5 -> 5 ; p1: 3,2,0,0 -> NoData (needs crop or veg) ; p2: 3,2,1,0 -> 3
got := run(kernelUMax, 1, []float64{1, 3, 3}, []float64{2, 2, 2}, []float64{0, 0, 1}, []float64{5, 0, 0})
if want := []int16{5, NoData, 3}; !reflect.DeepEqual(got[0], want) {
t.Fatalf("got %v want %v", got[0], want)
}
}
func TestKernelE(t *testing.T) {
got := run(kernelE, 1, []float64{2, 5, -9999}, []float64{4, 1, 3})
if want := []int16{4, 5, NoData}; !reflect.DeepEqual(got[0], want) {
t.Fatalf("got %v want %v", got[0], want)
}
}
func TestKernelCConflicts(t *testing.T) {
// 5 P then E; p0: E=3 -> min(P,3); p1: E invalid -> all NoData; p2: P2 invalid
e := []float64{3, 0, 4}
got := run(kernelCConflicts, 5,
[]float64{5, 5, 5}, []float64{1, 1, 7}, []float64{3, 3, 3}, []float64{4, 4, 4}, []float64{2, 2, 2}, e)
want := [][]int16{{3, NoData, 4}, {1, NoData, NoData}, {3, NoData, 3}, {3, NoData, 4}, {2, NoData, 2}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v want %v", got, want)
}
}
func TestKernelMaxAnyValid(t *testing.T) {
got := run(kernelMaxAnyValid, 1, []float64{1, 0, 0}, []float64{4, 0, 2}, []float64{-9999, 0, 0})
if want := []int16{4, NoData, 2}; !reflect.DeepEqual(got[0], want) {
t.Fatalf("got %v want %v", got[0], want)
}
}
// TZ §7 edge cases. Inputs: E, then C1..C5 (irrigation, snow, crop, pasture, tourism).
func TestKernelFuncZoning_TZEdgeCases(t *testing.T) {
e := []float64{0, 3, 5, 4, 2, 2, 3}
s1 := []float64{3, 0, 3, 5, 3, 2, 4}
s2 := []float64{3, 0, 3, 5, 3, 2, 4}
s3 := []float64{3, 0, 3, 2, 3, 2, 5}
s4 := []float64{3, 0, 3, 2, 3, 2, 1}
s5 := []float64{3, 0, 3, 2, 3, 2, 1}
got := run(kernelFuncZoning, 3, e, s1, s2, s3, s4, s5)
// pixel: 0 E invalid -> NoData ; 1 all S invalid -> NoData ; 2 E=5 -> regulated(1) everywhere ;
// 3 E=4, S=5 for sectors 1,2 -> V1 regulated, V2/V3 sector 1 -> class 2 ;
// 4 all S equal (3), E=2 -> irrigation (2) in all ; 5 E=2, S=2 -> V1 regulated (S<3), V2 best=irrigation (2), V3 irrigation (2)
// 6 E=3: S=[4,4,5,1,1] -> V1 (S>=4): winners 1,2,3 max 5 -> crop (4); V2 (S>=3) -> crop (4); V3 -> crop (4)
want := [][]int16{
{NoData, NoData, 1, 1, 2, 1, 4}, // V1
{NoData, NoData, 1, 2, 2, 2, 4}, // V2
{NoData, NoData, 1, 2, 2, 2, 4}, // V3
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v want %v", got, want)
}
}
func TestKernelFuncZoning_TiePriorityIsConfigurable(t *testing.T) {
in := [][]float64{{2}, {3}, {3}, {3}, {3}, {3}}
out := [][]int16{{0}, {0}, {0}}
p := DefaultParams()
p.TiePriority = []int{5, 4, 3, 2, 1}
kernelFuncZoning(in, out, p)
if out[0][0] != 6 || out[1][0] != 6 || out[2][0] != 6 {
t.Fatalf("expected tourism (6) everywhere, got %v", out)
}
}

View File

@@ -0,0 +1,81 @@
package landscape
import (
"errors"
"fmt"
)
// Params are the run-time knobs of the graph (TZ §6): window size, tie
// priority for functional zoning, and the V1/V2 suitability thresholds.
type Params struct {
WindowSize int `json:"window_size"`
TiePriority []int `json:"tie_priority"`
V1MinSE12 int `json:"v1_min_s_e12"`
V1MinSE3 int `json:"v1_min_s_e3"`
V2MinSE3 int `json:"v2_min_s_e3"`
V2MinSE4 int `json:"v2_min_s_e4"`
}
// DefaultParams returns the TZ defaults.
func DefaultParams() Params {
return Params{WindowSize: 2048, TiePriority: []int{1, 2, 3, 4, 5}, V1MinSE12: 3, V1MinSE3: 4, V2MinSE3: 3, V2MinSE4: 5}
}
// WithDefaults fills zero-valued fields from DefaultParams.
func (p Params) WithDefaults() Params {
d := DefaultParams()
if p.WindowSize == 0 {
p.WindowSize = d.WindowSize
}
if len(p.TiePriority) == 0 {
p.TiePriority = d.TiePriority
}
if p.V1MinSE12 == 0 {
p.V1MinSE12 = d.V1MinSE12
}
if p.V1MinSE3 == 0 {
p.V1MinSE3 = d.V1MinSE3
}
if p.V2MinSE3 == 0 {
p.V2MinSE3 = d.V2MinSE3
}
if p.V2MinSE4 == 0 {
p.V2MinSE4 = d.V2MinSE4
}
return p
}
// Validate checks ranges: window 256..8192, thresholds 1..5, tie_priority a
// permutation of 1..5.
func (p Params) Validate() error {
var errs []error
if p.WindowSize < 256 || p.WindowSize > 8192 {
errs = append(errs, fmt.Errorf("window_size must be in 256..8192"))
}
for name, v := range map[string]int{"v1_min_s_e12": p.V1MinSE12, "v1_min_s_e3": p.V1MinSE3, "v2_min_s_e3": p.V2MinSE3, "v2_min_s_e4": p.V2MinSE4} {
if v < 1 || v > 5 {
errs = append(errs, fmt.Errorf("%s must be in 1..5", name))
}
}
seen := map[int]bool{}
for _, k := range p.TiePriority {
if k < 1 || k > 5 || seen[k] {
errs = append(errs, fmt.Errorf("tie_priority must be a permutation of 1..5"))
break
}
seen[k] = true
}
if len(p.TiePriority) != 5 && len(seen) == len(p.TiePriority) {
errs = append(errs, fmt.Errorf("tie_priority must list all of 1..5"))
}
return errors.Join(errs...)
}
// ForNode keeps only the fields that influence the node's result (window size
// never does), so fingerprints do not change when irrelevant knobs change.
func (p Params) ForNode(code string) Params {
if code == "30" {
return Params{TiePriority: p.TiePriority, V1MinSE12: p.V1MinSE12, V1MinSE3: p.V1MinSE3, V2MinSE3: p.V2MinSE3, V2MinSE4: p.V2MinSE4}
}
return Params{}
}

View File

@@ -0,0 +1,58 @@
package landscape
import (
"reflect"
"testing"
)
func TestDefaultParamsValidate(t *testing.T) {
if err := DefaultParams().Validate(); err != nil {
t.Fatalf("DefaultParams().Validate() = %v, want nil", err)
}
}
func TestWithDefaultsFillsZeroFields(t *testing.T) {
got := Params{}.WithDefaults()
want := DefaultParams()
if !reflect.DeepEqual(got, want) {
t.Fatalf("Params{}.WithDefaults() = %+v, want %+v", got, want)
}
}
func TestValidate_TiePriorityDuplicateInvalid(t *testing.T) {
p := DefaultParams()
p.TiePriority = []int{1, 1, 2, 3, 4}
if err := p.Validate(); err == nil {
t.Fatal("expected error for tie_priority with duplicate values")
}
}
func TestValidate_WindowSizeOutOfRangeInvalid(t *testing.T) {
p := DefaultParams()
p.WindowSize = 100
if err := p.Validate(); err == nil {
t.Fatal("expected error for window_size below 256")
}
}
func TestForNode_NonFuncZoningNodeIsZero(t *testing.T) {
p := DefaultParams()
got := p.ForNode("00")
if !reflect.DeepEqual(got, Params{}) {
t.Fatalf("ForNode(00) = %+v, want zero value", got)
}
}
func TestForNode_FuncZoningHasNoWindowSize(t *testing.T) {
p := DefaultParams()
got := p.ForNode("30")
if got.WindowSize != 0 {
t.Fatalf("ForNode(30).WindowSize = %d, want 0", got.WindowSize)
}
if !reflect.DeepEqual(got.TiePriority, p.TiePriority) {
t.Fatalf("ForNode(30).TiePriority = %v, want %v", got.TiePriority, p.TiePriority)
}
if got.V1MinSE12 != p.V1MinSE12 || got.V1MinSE3 != p.V1MinSE3 || got.V2MinSE3 != p.V2MinSE3 || got.V2MinSE4 != p.V2MinSE4 {
t.Fatalf("ForNode(30) thresholds = %+v, want matching %+v", got, p)
}
}