Data Cubes

A data cube is the simplest useful analytics shape: facts keyed by a handful of dimensions, carrying one or more measures. In MeshWeaver the whole cube is mesh content — the dimension types are NodeType nodes, the dimension members are mesh nodes, the facts are mesh nodes, and even the formulas are data on dimension nodes.

This page builds a complete one: the balance sheet of Helvetia Vorsorge, a fictional Swiss pension fund — Position × Year × Currency → Amount, with computed positions like Total Assets and the Funding Ratio modelled out of the atomic positions and evaluated by business-rules scopes. The working node set ships in samples/Graph/Data/PensionFund/ — including the scopes themselves as node-native Code nodes (BalanceSheet/Source/BalanceSheetScopes.cs). Business rules / scopes ship as a plugin, which carries the scope engine and its tests that pin every number below.

Year Position Currency scopes Atomic value comes from a fact entry node Sum Σ weight · value(component position) Ratio numerator ÷ denominator — the funding ratio

The representation — everything is a mesh node

The cube ships exactly like every domain in the samples: each dimension gets its own NodeType node, its members are mesh nodes of that type, the Source/*.cs files are Code nodes compiled at runtime, and the facts are mesh nodes too:

PensionFund/
├── Position.json                   ← the dimension's NodeType node
├── Position/
│   ├── Source/Position.cs          ← Code node: the dimension record + formula model
│   ├── Cash.json … FreeFunds.json  ← 15 atomic position MEMBERS — mesh nodes
│   └── TotalAssets.json …          ← 6 COMPUTED positions — formulas as node content
├── Year.json · Year/2024.json …    ← reporting years
├── Currency.json · Currency/CHF.json …
├── BalanceSheetEntry.json
├── BalanceSheetEntry/2024-Cash.json …   ← 30 facts — one node per Position × Year
├── BalanceSheet.json               ← "config => config.ConfigureBalanceSheet()"
│   └── BalanceSheet/Source/        ← scopes, data loader, layout areas
└── Statement.json                  ← the report INSTANCE (nodeType PensionFund/BalanceSheet)

There is no Id property anywhere — a mesh node's identity is its path. A fact references its dimensions by their paths, and a formula references its operand positions by path. The views attach to instances of the BalanceSheet type, not to the type definition itself: open PensionFund/Statement in a portal with the samples loaded and the views below are its layout areas.

1. Dimension types host their instances

Each dimension is declared by a NodeType node whose Source/ holds the content record. The Position dimension is the interesting one — it carries the formula model:

public enum BalanceSheetSide { Assets, Liabilities, Computed }
public enum PositionAggregation { Atomic, Sum, Ratio }

public record PositionComponent
{
    [MeshNode("nodeType:PensionFund/Position")]   // ← references another Position NODE
    public string Position { get; init; } = string.Empty;
    public double Weight { get; init; } = 1;       //   +1 adds, −1 subtracts
}

public record Position
{
    public BalanceSheetSide Side { get; init; }
    public PositionAggregation Aggregation { get; init; }
    public PositionComponent[]? Components { get; init; }   // Sum operands

    [MeshNode("nodeType:PensionFund/Position")]
    public string? Numerator { get; init; }                  // Ratio
    [MeshNode("nodeType:PensionFund/Position")]
    public string? Denominator { get; init; }
}

Note what the record does not carry: no Id, no Name, no Description, no Order — all of those already live on the mesh node itself (MeshNode.Name, MeshNode.Description, MeshNode.Order). The content holds only what the node doesn't have: the formula model.

The [MeshNode("query")] attribute does double duty: it documents that the property holds a node path, and the Edit form renders it as the searchable MeshNodePicker over exactly the nodes the query matches — here, the members of the Position dimension.

2. Formulas are data on dimension nodes

Computed positions are ordinary Position nodes whose content holds the formula. Pension Capital — the actuarial obligation — is the sum of three other positions:

{
  "id": "PensionCapital",
  "namespace": "PensionFund/Position",
  "nodeType": "PensionFund/Position",
  "content": {
    "$type": "Position",
    "side": "Computed",
    "aggregation": "Sum",
    "components": [
      { "position": "PensionFund/Position/ActiveMembersCapital", "weight": 1 },
      { "position": "PensionFund/Position/PensionersCapital",   "weight": 1 },
      { "position": "PensionFund/Position/TechnicalProvisions", "weight": 1 }
    ]
  }
}

Available Assets uses negative weights (total assets minus short-term obligations), and the Funding Ratio is a Ratio position dividing it by Pension Capital — the statutory solvency measure of a Swiss pension fund (BVV2 Art. 44). Editing a formula is editing a node: add a component in the GUI and every report recomputes.

3. The fact — no Id, dimension columns are node paths

public record BalanceSheetEntry
{
    [MeshNode("nodeType:PensionFund/Position")]
    public string Position { get; init; } = string.Empty;   // a node PATH

    [MeshNode("nodeType:PensionFund/Year")]
    public string Year { get; init; } = string.Empty;

    [MeshNode("nodeType:PensionFund/Currency")]
    public string Currency { get; init; } = string.Empty;

    [DisplayFormat(DataFormatString = "{0:N1}")]
    public double Amount { get; init; }                      // CHF m
}

Thirty entries — 15 atomic positions × 2 years — make up the sample balance sheet, and both years balance by construction (2024: 1,060.0 · 2025: 1,142.0).

4. Business rules — scopes evaluate any position

The evaluation engine is one interface from the business-rules framework (MeshWeaver.BusinessRules): a scope per (position, year), composing other scopes for its operands. The scope generator emits the implementations at build time — for sample Code nodes, the NodeType compiler runs it during dynamic compilation:

public record PositionYear(string Position, string Year);

public interface PositionValue : IScope<PositionYear, BalanceSheetStorage>
{
    Position Position => GetStorage().Positions[Identity.Position];

    double Value => Position.Aggregation switch
    {
        PositionAggregation.Atomic =>
            GetStorage().Amounts.TryGetValue((Identity.Position, Identity.Year), out var v) ? v : 0,

        PositionAggregation.Sum =>
            (Position.Components ?? [])
                .Sum(c => c.Weight * GetScope<PositionValue>(new PositionYear(c.Position, Identity.Year)).Value),

        PositionAggregation.Ratio =>
            GetScope<PositionValue>(new PositionYear(Position.Denominator!, Identity.Year)).Value is var d && d != 0
                ? GetScope<PositionValue>(new PositionYear(Position.Numerator!, Identity.Year)).Value / d
                : 0,

        _ => 0,
    };
}

Scope instances are cached per identityTotal Assets feeds both Available Assets and the balance check, yet is computed once. Registration is one line in the node's hub configuration:

config.WithServices(services => services.AddBusinessRules(typeof(PositionValue).Assembly))

and any view evaluates positions through the registry:

var registry = host.Hub.ServiceProvider.CreateScopeRegistry(storage);
var ratio = registry.GetScope<PositionValue>(
    new PositionYear("PensionFund/Position/FundingRatio", "PensionFund/Year/2024")).Value;   // ≈ 109.8%

The same recursion, runnable right here — atomic values and formulas exactly as in the sample nodes, folded the way the PositionValue scope does:

// The formula model: Sum positions fold weighted components, Ratio divides.
record Component(string Position, double Weight);
record Pos(string Agg, Component[]? Components = null, string? Num = null, string? Den = null);

var amounts = new Dictionary<string, double>          // 2024 atomic facts, CHF m
{
    ["Cash"] = 50, ["Bonds"] = 400, ["Equities"] = 300, ["RealEstate"] = 200,
    ["Alternatives"] = 100, ["Receivables"] = 10,
    ["Payables"] = 15, ["AccruedLiabilities"] = 5, ["EmployerContributionReserve"] = 20,
    ["NonTechnicalProvisions"] = 10, ["ActiveMembersCapital"] = 600,
    ["PensionersCapital"] = 280, ["TechnicalProvisions"] = 40,
    ["ValueFluctuationReserve"] = 80, ["FreeFunds"] = 10,
};

Component[] Sum1(params string[] ps) => ps.Select(p => new Component(p, 1)).ToArray();
var positions = new Dictionary<string, Pos>
{
    ["TotalAssets"]     = new("Sum", Sum1("Cash", "Bonds", "Equities", "RealEstate", "Alternatives", "Receivables")),
    ["PensionCapital"]  = new("Sum", Sum1("ActiveMembersCapital", "PensionersCapital", "TechnicalProvisions")),
    ["AvailableAssets"] = new("Sum", new[] { new Component("TotalAssets", 1),
        new Component("Payables", -1), new Component("AccruedLiabilities", -1),
        new Component("EmployerContributionReserve", -1), new Component("NonTechnicalProvisions", -1) }),
    ["FundingRatio"]    = new("Ratio", Num: "AvailableAssets", Den: "PensionCapital"),
};

double Value(string p) => positions.TryGetValue(p, out var pos)
    ? pos.Agg switch
    {
        "Sum"   => pos.Components!.Sum(c => c.Weight * Value(c.Position)),
        "Ratio" => Value(pos.Num!) / Value(pos.Den!),
        _ => 0,
    }
    : amounts[p];                                      // Atomic: read the fact

Controls.Markdown($"""
| Computed position (2024) | Value |
|---|---:|
| Total Assets | {Value("TotalAssets"):N1} |
| Pension Capital | {Value("PensionCapital"):N1} |
| Available Assets | {Value("AvailableAssets"):N1} |
| **Funding Ratio** | **{Value("FundingRatio"):P1}** |
""")

5. Edit on the GUI — one call, no per-field code

host.Edit(instance) renders the whole form from the record's attributes. In the sample, the dimension fields carry [MeshNode] and render node pickers; here, live from the kernel with selects:

using System.ComponentModel.DataAnnotations;
using MeshWeaver.Layout;

record BalanceSheetEntryDraft
{
    [UiControl<SelectControl>(Options = new[] { "Cash", "Bonds", "Equities", "RealEstate" })]
    [Display(Name = "Position")]
    public string Position { get; init; } = "Equities";

    [UiControl<SelectControl>(Options = new[] { "2024", "2025" })]
    public string Year { get; init; } = "2025";

    [UiControl<SelectControl>(Options = new[] { "CHF", "EUR", "USD" })]
    public string Currency { get; init; } = "CHF";

    [DisplayFormat(DataFormatString = "{0:N1}")]
    [Display(Name = "Amount (CHF m)")]
    public double Amount { get; init; } = 340.0;
}

Mesh.Edit(new BalanceSheetEntryDraft(), "pensionDraft")

6. The picker in a dialog

Opening the same form as a modal dialog is one click action — build the dialog, write it to the dialog area. In the sample this is the NewEntryDialog view, whose draft uses the real [MeshNode] pickers over the Position / Year / Currency nodes:

Controls.Button("New balance sheet entry…")
    .WithClickAction(click =>
    {
        var dialog = Controls.Dialog(
                click.Host.Edit(new BalanceSheetEntryDraft(), "newEntry"),
                "New Balance Sheet Entry")
            .WithSize("M")
            .WithClosable(true);
        click.Host.UpdateArea(DialogControl.DialogArea, dialog);   // open
        return Task.CompletedTask;
    });
// close from any action: ctx.Host.UpdateArea(DialogControl.DialogArea, null!);

Reactive dialog patterns (spinners, conditional sections, server round-trips): Reactive Dialogs.

7. Slice & dice — the pivot table

Rows by Position, columns by Year — with totals. Live:

using MeshWeaver.Layout.Pivot;

record Entry(string Position, string Year, double Amount);

var entries = new Dictionary<string, (double Y2024, double Y2025)>
{
    ["Cash"] = (50, 60), ["Bonds"] = (400, 410), ["Equities"] = (300, 340),
    ["RealEstate"] = (200, 210), ["Alternatives"] = (100, 110), ["Receivables"] = (10, 12),
}
.SelectMany(kvp => new[] { new Entry(kvp.Key, "2024", kvp.Value.Y2024), new Entry(kvp.Key, "2025", kvp.Value.Y2025) })
.ToArray();

entries.ToPivotGrid(pivot => pivot
    .GroupRowsBy(e => e.Position)
    .GroupColumnsBy(e => e.Year)
    .Aggregate(e => e.Amount, agg => agg.WithFunction(AggregateFunction.Sum))
    .WithRowTotals()
    .WithColumnTotals())

Re-dice it yourself: the field picker on the grid lets you drag dimensions between rows and columns — the cube re-aggregates live.

8. Slice & dice — the charts

The asset side, stacked by position across the years:

using MeshWeaver.Layout.Chart;

record Entry(string Position, string Year, double Amount);

var entries = new Dictionary<string, (double Y2024, double Y2025)>
{
    ["Cash"] = (50, 60), ["Bonds"] = (400, 410), ["Equities"] = (300, 340),
    ["RealEstate"] = (200, 210), ["Alternatives"] = (100, 110), ["Receivables"] = (10, 12),
}
.SelectMany(kvp => new[] { new Entry(kvp.Key, "2024", kvp.Value.Y2024), new Entry(kvp.Key, "2025", kvp.Value.Y2025) })
.ToArray();

entries
    .SliceBy(e => e.Year)
    .SliceBy(e => e.Position)
    .ToStackedColumnChart(g => g.Sum(e => e.Amount))
    .WithTitle("Assets by Year and Position (CHF m)")

And the 2025 asset allocation as a pie — the same chart the sample's AssetAllocation view renders from the scopes:

using MeshWeaver.Layout.Chart;

record Entry(string Position, double Amount);

var assets2025 = new[]
{
    new Entry("Bonds", 410.0), new Entry("Equities", 340.0), new Entry("RealEstate", 210.0),
    new Entry("Alternatives", 110.0), new Entry("Cash", 60.0), new Entry("Receivables", 12.0),
};

assets2025
    .SliceBy(e => e.Position)
    .ToPieChart(g => g.Sum(e => e.Amount))
    .WithTitle("Asset Allocation 2025 (CHF m)")

9. The numbers — pinned by tests

Every figure this page shows is asserted by the business-rules plugin's tests — evaluated through the real generated scopes, the same node-native engine the sample's Code nodes compile against:

Figure 2024 2025
Total Assets = Balance Sheet Sum = Total Liabilities 1,060.0 1,142.0
Pension Capital 920.0 964.0
Available Assets 1,010.0 1,084.0
Funding Ratio ≈ 109.8% ≈ 112.4%

See Also

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.