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 connection to the server was interrupted. Trying to restore it…
Trying again…
The connection could not be restored. Reloading the page…
The server was updated. Reloading the page to pick up the latest version.