using Envoy.GhostDetection.Models;
namespace Envoy.GhostDetection.Signals;
///
/// Probabilistic signal: score a posting's age relative to role-type baselines.
/// A 90-day senior engineering search is normal; a 90-day entry-level posting is
/// suspicious. We normalize by seniority inferred from the job title.
///
public class PostingAgeSignal : IGhostSignal
{
public string Name => "Posting Age";
public SignalTier Tier => SignalTier.Probabilistic;
public bool RequiresNetwork => false;
public Task EvaluateAsync(JobPosting posting, CancellationToken ct = default)
{
var ageDays = ComputeAgeDays(posting);
if (ageDays == null)
return Task.FromResult(null);
var seniority = InferSeniority(posting.JobTitle);
var baselineDays = BaselineForSeniority(seniority);
// Fresh enough — no opinion
if (ageDays.Value <= baselineDays)
return Task.FromResult(null);
var (score, confidence) = ComputeScoreAndConfidence(ageDays.Value, baselineDays, seniority);
var evidence = BuildEvidence(ageDays.Value, baselineDays, seniority, posting.JobTitle);
return Task.FromResult(new SignalResult
{
SignalName = Name,
Score = score,
Confidence = confidence,
Evidence = evidence,
Tier = Tier
});
}
// ── Age computation ──────────────────────────────────────────────────
private static int? ComputeAgeDays(JobPosting posting)
{
var referenceDate = posting.PostedAtUtc ?? posting.LastUpdatedUtc;
if (referenceDate == null)
return null;
var age = DateTime.UtcNow - referenceDate.Value;
return (int)age.TotalDays;
}
// ── Seniority inference ──────────────────────────────────────────────
private static Seniority InferSeniority(string title)
{
var t = (title ?? string.Empty).ToLowerInvariant();
// Executive / very senior
if (ContainsAny(t, "vice president", "head of") ||
ContainsAnyWord(t, "chief", "cto", "cfo", "cio", "coo", "vp", "director"))
return Seniority.Executive;
// Senior
if (ContainsAny(t, "sr ", "sr.") ||
ContainsAnyWord(t, "senior", "staff", "principal", "lead", "architect", "manager", "mgr"))
return Seniority.Senior;
// Junior / entry
if (ContainsAny(t, "jr ", "jr.", "co-op") ||
ContainsAnyWord(t, "junior", "entry", "intern", "associate", "trainee", "apprentice", "coop", "coordinator"))
return Seniority.Junior;
return Seniority.Mid;
}
private static bool ContainsAny(string haystack, params string[] needles)
{
foreach (var n in needles)
{
if (haystack.Contains(n, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
///
/// Like but requires the keyword to appear as a whole word
/// (or at a word boundary) so "coordinator" does not match "director".
///
private static bool ContainsAnyWord(string haystack, params string[] needles)
{
foreach (var n in needles)
{
int idx = haystack.IndexOf(n, StringComparison.OrdinalIgnoreCase);
if (idx < 0) continue;
bool startBoundary = idx == 0 || !char.IsLetterOrDigit(haystack[idx - 1]);
bool endBoundary = idx + n.Length == haystack.Length || !char.IsLetterOrDigit(haystack[idx + n.Length]);
if (startBoundary && endBoundary)
return true;
}
return false;
}
// ── Baselines ──────────────────────────────────────────────────────
private static int BaselineForSeniority(Seniority s) => s switch
{
Seniority.Junior => 30,
Seniority.Mid => 60,
Seniority.Senior => 90,
Seniority.Executive => 120,
_ => 60
};
private static string SeniorityLabel(Seniority s) => s switch
{
Seniority.Junior => "entry-level / junior",
Seniority.Mid => "mid-level",
Seniority.Senior => "senior / lead",
Seniority.Executive => "executive / director",
_ => "unspecified-level"
};
// ── Scoring ────────────────────────────────────────────────────────
private static (double Score, double Confidence) ComputeScoreAndConfidence(int ageDays, int baseline, Seniority seniority)
{
double score;
if (ageDays <= baseline * 2)
{
// baseline -> 2x baseline maps to 0.30 -> 0.55
var t = (double)(ageDays - baseline) / baseline;
score = 0.30 + t * 0.25;
}
else if (ageDays <= baseline * 3)
{
// 2x -> 3x baseline maps to 0.60 -> 0.80
var t = (double)(ageDays - baseline * 2) / baseline;
score = 0.60 + t * 0.20;
}
else
{
// Beyond 3x baseline — strong signal but cap below deterministic territory
score = Math.Min(0.88, 0.80 + (ageDays - baseline * 3) / 500.0);
}
// Confidence: higher when seniority is clearly inferred and we have a real PostedAtUtc
double confidence = 0.65;
if (seniority != Seniority.Mid)
confidence += 0.10; // title had clear seniority keyword
// (we can't tell from JobPosting whether PostedAtUtc or LastUpdatedUtc was used,
// so we keep the base confidence moderate)
confidence = Math.Min(0.92, confidence);
return (Math.Round(score, 2), Math.Round(confidence, 2));
}
// ── Evidence ───────────────────────────────────────────────────────
private static string[] BuildEvidence(int ageDays, int baseline, Seniority seniority, string title)
{
var lines = new List
{
$"Posted {ageDays} days ago for a {SeniorityLabel(seniority)} role (typical fill time: ~{baseline} days)."
};
if (ageDays > baseline * 3)
lines.Add("This is well beyond the typical fill window for this seniority level.");
else if (ageDays > baseline * 2)
lines.Add("This is significantly older than the typical fill window for this seniority level.");
else
lines.Add("This is somewhat older than the typical fill window for this seniority level.");
return lines.ToArray();
}
// ── Internal model ─────────────────────────────────────────────────
private enum Seniority { Junior, Mid, Senior, Executive }
}