diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml
new file mode 100644
index 0000000..7f33711
--- /dev/null
+++ b/.github/workflows/windows.yml
@@ -0,0 +1,113 @@
+name: windows
+
+# The Windows launcher's setup path on a cloud Windows machine: the launcher parses in both PowerShells, the locked
+# Python environment installs, CICFlowMeter builds through gradlew.bat, the forecast service's Java build works and
+# the published model downloads. Packet capture is not covered: Npcap has no unattended installer.
+on:
+ pull_request:
+ paths:
+ - start-netwatch.ps1
+ - start-netwatch.cmd
+ - tools/repo/cicflowmeter.gradle
+ - tools/live/StreamMeter.java
+ - ingest/sources/flows.py
+ - models/serving/live.py
+ - tools/publish/download.py
+ - .gitmodules
+ - uv.lock
+ - .github/workflows/windows.yml
+ workflow_dispatch:
+
+jobs:
+ launcher-setup:
+ runs-on: windows-latest
+ timeout-minutes: 45
+ env:
+ PYTHONPATH: .
+ PYTHONUTF8: "1"
+ TORCH_DISABLE_NATIVE_JIT: "1"
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: true
+
+ - name: Set up JDK 8
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "8"
+
+ # Windows PowerShell 5.1 is what start-netwatch.cmd runs, and it reads a BOM-less script as ANSI.
+ - name: Launcher parses and answers -Help (Windows PowerShell 5.1)
+ shell: powershell
+ run: |
+ $errors = $null
+ [void][System.Management.Automation.Language.Parser]::ParseFile("$PWD\start-netwatch.ps1", [ref]$null, [ref]$errors)
+ if ($errors) { $errors | ForEach-Object { Write-Host $_ }; exit 1 }
+ cmd /c start-netwatch.cmd -Help
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+
+ - name: Launcher parses and answers -Help (PowerShell 7)
+ shell: pwsh
+ run: |
+ $errors = $null
+ [void][System.Management.Automation.Language.Parser]::ParseFile("$PWD\start-netwatch.ps1", [ref]$null, [ref]$errors)
+ if ($errors) { $errors | ForEach-Object { Write-Host $_ }; exit 1 }
+ ./start-netwatch.ps1 -Help
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v5
+
+ - name: Python environment, as the launcher installs it
+ run: uv sync --frozen
+
+ - name: CICFlowMeter builds through gradlew.bat, as the launcher runs it
+ shell: pwsh
+ run: |
+ $commit = (git -C tools/CICFlowMeter rev-parse HEAD).Trim()
+ if ($commit -ne 'acaf8bea8611fb4b996b4d33964dfd9155d9efdf') { throw "CICFlowMeter is at $commit, not the pinned commit" }
+ $override = Join-Path $PWD 'tools\repo\cicflowmeter.gradle'
+ Push-Location tools\CICFlowMeter
+ & .\gradlew.bat -q -I $override compileJava '-PpcapDir=-' '-PoutputDir=-'
+ $code = $LASTEXITCODE
+ Pop-Location
+ if ($code -ne 0) { exit $code }
+
+ # The build steps of the forecast service's meter, without its capture pipe: jnetpcap.dll needs Npcap to load.
+ - name: Forecast service builds its Java meter
+ shell: pwsh
+ run: |
+ @'
+ import os
+ import tempfile
+ from pathlib import Path
+
+ from models.serving.live import FlowStream
+
+ meter = object.__new__(FlowStream)
+ meter.cic = Path("tools/CICFlowMeter").resolve()
+ meter.folder = Path(tempfile.mkdtemp(prefix="meter-"))
+ classpath = meter.classpath()
+ assert "jnetpcap" in classpath, classpath
+ classes = meter.compile()
+ assert (classes / "StreamMeter.class").exists(), "StreamMeter did not compile"
+ print("classpath entries:", len(classpath.split(os.pathsep)), "| StreamMeter compiled")
+ '@ | Set-Content -Encoding utf8 meter_check.py
+ uv run python meter_check.py
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+
+ - name: Published model downloads and rebuilds, as the launcher fetches it
+ shell: pwsh
+ run: |
+ uv run --frozen --with huggingface_hub python tools/publish/download.py --repo kaustuk000/netwatch-flow-cascade --revision v1.0.0
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ $downloaded = Join-Path $PWD 'artifacts\huggingface\download\netwatch-flow-cascade'
+ New-Item -ItemType Junction -Path (Join-Path $PWD 'artifacts\current') -Target $downloaded | Out-Null
+ foreach ($relative in @('flow_encoder.pt', 'detection_encoder.pt', 'forecasting_encoder.pt',
+ 'compressor.pt', 'world_model.pt', 'serving.json')) {
+ if (-not (Test-Path -LiteralPath (Join-Path $PWD "artifacts\current\$relative"))) {
+ throw "Model checkpoint missing through the junction: $relative"
+ }
+ }
+ if (-not (Get-ChildItem artifacts\current\detector -Filter 'head_*.pt')) { throw 'No detector heads found' }
diff --git a/README.md b/README.md
index 5e3257b..30b93c2 100644
--- a/README.md
+++ b/README.md
@@ -113,8 +113,9 @@ elsewhere. Detection starts at once; the forecast needs a little over four minut
ingest used, fetched, patched and built by `tools/setup_cicflowmeter.sh`; the published model (release `v1.0.0`) from
Hugging Face into `artifacts/huggingface/download/netwatch-flow-cascade/`, with `artifacts/current` pointed at it so
every serving command serves it. It then asks whether to download the dataset, and which set (`processed`, `model` or
-both) and which days; `--dataset none|processed|model|both` answers without asking. The dataset is gated: accept its
-terms on Hugging Face and log in first.
+both) and which days; `--dataset none|processed|model|both` answers without asking. The model is open and needs no
+account. The dataset is gated: accept its terms on Hugging Face; the download asks you to log in if this machine has no
+Hugging Face login yet.
```bash
git clone --recurse-submodules https://github.com/Harsh-DevOps117/netwatch && cd netwatch
diff --git a/cli/cmd/dashboard.go b/cli/cmd/dashboard.go
index 5ad7caf..60f210e 100644
--- a/cli/cmd/dashboard.go
+++ b/cli/cmd/dashboard.go
@@ -401,7 +401,7 @@ func dashboardHandler(store *dashboardStore, modelClient *modelapi.Client) http.
mux.HandleFunc("/api/windows", func(w http.ResponseWriter, r *http.Request) { write(w, store.snapshot()["history"]) })
mux.HandleFunc("/api/system", func(w http.ResponseWriter, r *http.Request) { write(w, store.snapshot()["system"]) })
mux.HandleFunc("/api/forecast", func(w http.ResponseWriter, r *http.Request) {
- forecast, err := modelClient.Forecast(r.Context())
+ forecast, err := modelClient.ForecastWithRules(r.Context())
if err != nil {
write(w, map[string]interface{}{"status": "NOT_CONNECTED", "reason": err.Error(), "current_state": "S[t]", "future_states": []interface{}{}, "predicted_stage": ""})
return
diff --git a/cli/cmd/dashboard_ui.go b/cli/cmd/dashboard_ui.go
index 7d1c019..7b754e8 100644
--- a/cli/cmd/dashboard_ui.go
+++ b/cli/cmd/dashboard_ui.go
@@ -178,10 +178,11 @@ body.graph-modal-open{overflow:hidden}
State explorer
Observed state plus selected predictions
ObservedHypothetical linkAlternativeSeverity set
S[t]Next imagined events; no clock-time ETA
Blue links were observed; red links are hypothetical event steps. Swipe to inspect peers.
Operating point
Calibration and measured test behavior
Original checkpoint is the default.
Relative risk by event step
Served score against the signed threshold; this is not probability
-
World-model attention matrix
Seed events by attended peer; actual averaged model weights
— means that peer is not one of that seed’s reported peers; 0.0% means it is, and the model gave it no weight. Neither is a security verdict; only the eight strongest peers are shown.
+
World-model attention matrix
Latest scored events and rollout seeds by attended peer; actual averaged model weights
— means that peer is not one of that event’s reported peers; 0.0% means it is, and the model gave it no weight. Neither is a security verdict; only the eight strongest peers are shown.
Hypothetical next links
Ranked by model score. Each +k is an event step from the observed state, not a time estimate or an incident. Path probabilities are intentionally not displayed.
Only new observed incidents trigger popups; event flags and forecast links do not.
+
Observed world-model incidents
Click an incident to see its contributing scored events and source/destination IPs. Three threshold crossings on one link; 120-second quiet gap.
Opened
Observed link
Opening score
Flagged events
Severity
Caveats from the service
All service-provided limits are shown.
@@ -330,7 +331,7 @@ function renderForecast(f){forecast=f||{};let connected=forecast.status==='CONNE
renderForecastTime();
let recall=t?Number(t.recall||0):null;$('recallWarning').innerHTML=!t?'
Checkpoint is uncalibrated.
Nothing alerts until a threshold is served.
':recall===0?'
Measured test recall is 0.000%.
This checkpoint is not a working detector yet. Forecast alerts must not be treated as primary detections.
':'
Forecast alerts are secondary.
Measured test recall is '+(100*recall).toFixed(3)+'%. Use the early detection service as the primary alert source.
';
$('calibrationCards').innerHTML=kpi('Source tag',src.tag||src.mode||'unknown',src.mode==='replay'?'recorded dataset':'served forecast product')+kpi('Model score',forecast.score||'unknown','score, not calibrated probability')+kpi('Rollout',number(forecast.rollout_steps)+' steps','one imagined event per step')+kpi('Events scored',number(forecast.events_scored),'service total');
- $('thresholdDetail').innerHTML=t?row('Rule',t.rule||'—')+row('Calibrated on',t.calibrated_on||'—')+row('Measured recall',(100*Number(t.recall||0)).toFixed(3)+'%')+row('Measured FPR',(100*Number(t.fpr||0)).toFixed(4)+'%')+row('False alarms',number(t.false_alarms)+' / '+Number(t.test_hours||0).toFixed(2)+' h')+row('False incidents / h',Number(t.false_incidents_per_hour||0).toFixed(3))+row('Budget',Number(t.budget||0).toPrecision(3)):row('Threshold','uncalibrated');
+ $('thresholdDetail').innerHTML=t?row('Rule',t.rule||'—')+row('Calibrated on',t.calibrated_on||'—')+row('Budget',(100*Number(t.budget||0)).toPrecision(3)+'% of benign traffic may alert')+row('Dataset test: recall',(100*Number(t.recall||0)).toFixed(3)+'%')+row('Dataset test: false-alarm rate',(100*Number(t.fpr||0)).toFixed(4)+'%'+(t.overshoot_vs_budget?' · '+Number(t.overshoot_vs_budget).toFixed(2)+'× the budget':''))+row('Dataset test: false alarms',number(t.false_alarms)+' in '+number(t.test_benign)+' benign events / '+Number(t.test_hours||0).toFixed(2)+' h')+row('Dataset test: false incidents / h',Number(t.false_incidents_per_hour||0).toFixed(3)):row('Threshold','uncalibrated');
renderState();renderRisk();renderPredictions();renderWorldAlerts();renderAttention();renderCaveats();renderWorldCalibration();
}
@@ -366,19 +367,21 @@ $('graphScaleToggle').addEventListener('click',function(){let detail=$('modelGra
$('closeModelGraph').addEventListener('click',function(){$('modelGraphDialog').close()});
$('modelGraphDialog').addEventListener('click',function(event){if(event.target===this)this.close()});
$('modelGraphDialog').addEventListener('close',function(){document.body.classList.remove('graph-modal-open')});
-function renderState(){if(!forecast||forecast.status!=='CONNECTED')return;let observed=(forecast.observed||{}).edges||[],predicted=(forecast.predicted_edges||[]).filter(function(e){return Number(e.step)<=selectedStep}),current=(forecast.predicted_edges||[]).filter(function(e){return Number(e.step)===selectedStep}),alts=[];current.forEach(function(e){(e.candidates||[]).slice(0,3).forEach(function(c){alts.push({a:e.sender_ip,b:c.ip,severity:c.severity||''})})});let src=forecast.source||{},age=src.mode==='live'&&src.t?'latest scored event '+ageSince(src.t):'recorded replay',paths=new Set(predicted.map(function(e){return e.seed_event})).size;$('stateLabel').textContent=selectedStep===0?(forecast.current_state||'S[t]'):'S[t+'+selectedStep+']';$('stateContext').textContent=selectedStep===0?'Observed input only · '+age:('Observed input plus '+predicted.length+' hypothetical links across '+paths+' seed paths through event step +'+selectedStep+' · '+age);$('stateStepMeaning').textContent=selectedStep===0?'Observed model state; no imagined events':'Step +'+selectedStep+' per seed path · no ETA';$('expandedStateStepMeaning').textContent=$('stateStepMeaning').textContent;$('stateTimingNote').textContent=selectedStep===0?'Blue links were observed. The state timestamp above shows how far behind live capture it is.':'Red links are hypothetical, not detected incidents. Event step +'+selectedStep+' has no time ETA and may already have occurred since the delayed input state.';$('stateStep').value=String(selectedStep);$('expandedStateStep').value=String(selectedStep);$('expandedStateLabel').textContent=$('stateLabel').textContent;$('expandedStateContext').textContent=$('stateContext').textContent;setTopologyGraph('modelGraph',topologySVG(observed,predicted,alts,src.local_ips||[],selectedStep,'state'));if($('modelGraphDialog').open)setTopologyGraph('expandedModelGraph',topologySVG(observed,predicted,alts,src.local_ips||[],selectedStep,'expanded'))}
+function renderState(){if(!forecast||forecast.status!=='CONNECTED')return;let observed=(forecast.observed||{}).edges||[],predicted=(forecast.predicted_edges||[]).filter(function(e){return Number(e.step)<=selectedStep}),current=(forecast.predicted_edges||[]).filter(function(e){return Number(e.step)===selectedStep}),alts=[];current.forEach(function(e){(e.candidates||[]).slice(0,3).forEach(function(c){alts.push({a:e.sender_ip,b:c.ip,severity:c.severity||''})})});let hidden=new Set(((forecast.observed||{}).suppressed_links||[]).map(function(x){return x.key})),drawn=function(a,b){return !hidden.has([String(a),String(b)].sort().join('|'))};if(hidden.size){observed=observed.filter(function(e){return drawn(e.sender_ip,e.receiver_ip)});predicted=predicted.filter(function(e){return drawn(e.sender_ip,e.receiver_ip)});alts=alts.filter(function(e){return drawn(e.a,e.b)})}let src=forecast.source||{},age=src.mode==='live'&&src.t?'latest scored event '+ageSince(src.t):'recorded replay',paths=new Set(predicted.map(function(e){return e.seed_event})).size;$('stateLabel').textContent=selectedStep===0?(forecast.current_state||'S[t]'):'S[t+'+selectedStep+']';$('stateContext').textContent=(selectedStep===0?'Observed input only · '+age:('Observed input plus '+predicted.length+' hypothetical links across '+paths+' seed paths through event step +'+selectedStep+' · '+age))+(hidden.size?' · '+hidden.size+' routine-service link(s) hidden by service rules':'');$('stateStepMeaning').textContent=selectedStep===0?'Observed model state; no imagined events':'Step +'+selectedStep+' per seed path · no ETA';$('expandedStateStepMeaning').textContent=$('stateStepMeaning').textContent;$('stateTimingNote').textContent=selectedStep===0?'Blue links were observed. The state timestamp above shows how far behind live capture it is.':'Red links are hypothetical, not detected incidents. Event step +'+selectedStep+' has no time ETA and may already have occurred since the delayed input state.';$('stateStep').value=String(selectedStep);$('expandedStateStep').value=String(selectedStep);$('expandedStateLabel').textContent=$('stateLabel').textContent;$('expandedStateContext').textContent=$('stateContext').textContent;setTopologyGraph('modelGraph',topologySVG(observed,predicted,alts,src.local_ips||[],selectedStep,'state'));if($('modelGraphDialog').open)setTopologyGraph('expandedModelGraph',topologySVG(observed,predicted,alts,src.local_ips||[],selectedStep,'expanded'))}
function renderRisk(){let values=forecast.step_value||[],shares=forecast.alert_share||[];if(!values.length){$('riskChart').innerHTML=empty('No step scores','The service did not return step_value.');return}let w=800,h=230,p=40,t=forecast.threshold,threshold=t?Number(t.direction||1)*Number(t.threshold||0):null,all=values.slice();if(threshold!=null)all.push(threshold);let min=Math.min(...all),max=Math.max(...all);if(max===min){max+=1;min-=1}let xy=function(v,i){return [p+(w-p*2)*(values.length===1?0:i/(values.length-1)),h-p-(h-p*2)*(v-min)/(max-min)]};let pts=values.map(xy),path='M '+pts.map(function(x){return x[0].toFixed(1)+' '+x[1].toFixed(1)}).join(' L '),thresholdLine='';if(threshold!=null){let y=xy(threshold,0)[1];thresholdLine='threshold '+score(threshold)+''}let dots=pts.map(function(x,i){return ''+(i+1)+'Step '+(i+1)+': score '+score(values[i])+', alert share '+Number(shares[i]||0).toFixed(3)+''}).join('');$('riskChart').innerHTML=''}
function renderPredictions(){let items=(forecast.predicted_edges||[]).slice(),direction=forecast.threshold?Number(forecast.threshold.direction||1):1;items.sort(function(a,b){return Number(a.step)-Number(b.step)||(direction*Number(b.value)-direction*Number(a.value))});if(!items.length){$('predictionList').innerHTML=empty('No predicted links','The connected service returned no rollout edges.');return}$('predictionList').innerHTML=items.slice(0,30).map(function(x,i){return '
'}).join('')}
function renderWorldAlerts(){
let source=forecast.observed||{},incidents=(source.incidents||[]).slice().reverse();
$('worldIncidentRows').innerHTML=incidents.length?incidents.map(function(x,index){let key=x.id||[x.opened,x.sender_ip,x.receiver_ip].join('|'),open=selectedWorldIncident===key,when=forecast.source&&forecast.source.mode==='live'?' · '+ageSince(x.opened):'';return '
'+esc(x.sender_ip)+' → '+esc(x.receiver_ip)+'
'+score(x.opening_score)+'
'+number(x.events)+'
'+severityBadge(x.severity)+'
'+(open?'
'+worldIncidentDetail(x)+'
':'')}).join(''):'
No observed world-model incidents in the current window.
';
+ let routine=source.suppressed_links||[],setAside=(forecast.suppressed_alerts||[]).length;
+ $('worldRuleSummary').innerHTML=routine.length?'
'+number(routine.length)+' link(s) judged routine internal services by deterministic service rules and hidden from the state explorer: '+number((source.suppressed_alerts||[]).length)+' flagged event(s), '+number((source.suppressed_incidents||[]).length)+' incident(s) and '+number(setAside)+' forecast alert(s) set aside. Raw model scores remain in the API. A service port alone is not a whitelist.
These are scored threshold crossings on this link, not imagined forecast events. Service tags are deterministic context, not proof of safety.'+(incident.rule_reason?' Rule: '+esc(incident.rule_reason)+'.':'')+'
'}
$('worldIncidentRows').addEventListener('click',function(event){let button=event.target.closest('button[data-world-index]');if(!button)return;let incidents=((forecast||{}).observed||{}).incidents||[],item=incidents.slice().reverse()[Number(button.dataset.worldIndex)];if(!item)return;let key=item.id||[item.opened,item.sender_ip,item.receiver_ip].join('|');selectedWorldIncident=selectedWorldIncident===key?null:key;renderWorldAlerts()});
function renderAttentionMatrix(id,items,options){let box=$(id),rows=(items||[]).slice(-8),totals={};rows.forEach(function(row){(row.attended||[]).forEach(function(peer){let key=String(options.peer(peer));totals[key]=(totals[key]||0)+Number(peer.attention||0)})});let columns=Object.keys(totals).sort(function(a,b){return totals[b]-totals[a]}).slice(0,8);if(!rows.length||!columns.length){box.innerHTML=empty('Waiting for attention','The matrix appears after a scored event has a valid neighbourhood.');return}let label=function(column,index){return options.compactColumns?'E'+(index+1):column};let head='
'}).join(''):empty('No caveats returned','The service did not include caveat text.')}
function detectionThreshold(value){let threshold=value&&typeof value==='object'?value.threshold:value;return threshold==null?'unavailable':Number(threshold).toFixed(4)}
@@ -398,7 +401,7 @@ renderDetections=function(d){
let total=Number(d&&d.total_ip_packets||0),ipv6=Number(d&&d.ipv6_packets_excluded||0),share=total?100*ipv6/total:0;
$('detectionInputWarning').innerHTML=share>=10?'
'+share.toFixed(1)+'% of parsed IP packets ('+number(ipv6)+' of '+number(total)+') are IPv6 and not scored by this IPv4-trained checkpoint. The displayed incident rate applies only to eligible IPv4 TCP/UDP flows. IPv6 needs a separately trained and validated model.
':'';
let suppressed=(d&&d.suppressed_incidents)||[];
- $('serviceRuleSummary').innerHTML=suppressed.length?'
'+number(suppressed.length)+' INFILTRATION incident(s) suppressed by deterministic service rules. Raw model scores and incidents remain in the API. A service port alone is not a whitelist.
'+number(suppressed.length)+' model incident(s) suppressed by deterministic service rules. Raw model scores and incidents remain in the API. A service port alone is not a whitelist.
':'';
};
function renderDetectionEvents(){
processAlarms('detection',(detections.recent_incidents||detections.incidents||[]).map(function(x){return {key:[x.family,x.incident,x.t].join('|'),label:x.family+' on sender node '+x.key}}))
@@ -551,7 +554,7 @@ const renderOfflineResultBase=renderOfflineResult;
renderOfflineResult=function(result){
renderOfflineResultBase(result);
let suppressed=((result||{}).detection||{}).suppressed_incidents||[];
- if(suppressed.length)$('offlineResult').insertAdjacentHTML('afterbegin','
'+number(suppressed.length)+' INFILTRATION incident(s) suppressed by deterministic internal-service rules. Original model incidents remain in raw_incidents in the job result.
'+number(suppressed.length)+' model incident(s) suppressed by deterministic internal-service rules. Original model incidents remain in raw_incidents in the job result.